Compare commits

..
Author SHA1 Message Date
Gemini Nexus Bot a648f2a281 fix: token tracking pipeline + premium usage dashboard redesign
Backend fixes:
- Fix totalTokens defaulting to 0 when API omits totalTokenCount (now computes prompt+completion)
- Fix streaming metadata extraction: unwrap response wrapper in Anthropic/Gemini handlers
- Fix Zod schemas: handle SQL SUM(null) with nullable().transform()
- Add is_estimated column migration to token_usage table

Frontend redesign:
- Rewrite Usage page with area charts (nivo/line), horizontal bar model ranking
- StatCard: add trend indicators (% vs previous period) and inline SVG sparklines
- Premium color palette, prompt/completion ratio bar, top models mini-list
- Live indicator with 15s auto-refresh

Design system migration:
- Standardize all components on Gemini Nexus tokens (rounded-xl, border-white/[0.06])
- Refine UI components, proxy tabs, cloud account list, provider groups
2026-04-29 01:43:48 -03:00
Gemini Nexus Bot b1c76f3b69 style(components): flatten usage cards 2026-04-28 04:01:31 -03:00
Gemini Nexus Bot 1ad7ae6515 style(components): flatten tabs component design 2026-04-28 03:59:55 -03:00
Gemini Nexus Bot fbf21c4fa9 style(components): flatten CloudAccountList header structure 2026-04-28 03:58:27 -03:00
Gemini Nexus Bot d35db9756d style(components): flatten AccountCard hover effects 2026-04-28 03:56:42 -03:00
Gemini Nexus Bot 3088c9c542 style(layout): soften sidebar border and background 2026-04-28 03:54:40 -03:00
Gemini Nexus Bot 24142115c4 style(components): refine core UI components for minimal aesthetics
- card: switched to subtle borders (border-white/5)
- input: increased height to h-10, enhanced focus transitions, removed internal shadows
- button: removed default shadows for flatter appearance
2026-04-28 03:53:45 -03:00
Gemini Nexus Bot bac7b5591e style(design): apply Minimal Dark Mode design system\n\n- Switched to complete Dark Mode (OLED-friendly)\n- Established standard color palette matching modern technical aesthetics\n- Changed font to Plus Jakarta Sans for increased legibility\n- Enforced dark mode as default in App.tsx\n- Fixed several React cascading render (setState in useEffect) warnings 2026-04-28 03:49:34 -03:00
Gemini Nexus Bot b80531f8a8 Merge branch 'feature/token-usage-counter' 2026-04-27 23:47:37 -03:00
Gemini Nexus Bot 3573009e0a fix(security): resolve 32 pnpm audit vulnerabilities
Upgrade direct dependencies to patched versions:
- electron: 37.3.1 -> 39.8.5
- vite: 5.4.21 -> 6.4.2
- @tailwindcss/vite: 4.1.17 -> 4.2.4
- fastify: 5.8.4 -> 5.8.5
- uuid: 13.0.0 -> 14.0.0
- Remove @types/uuid (uuid 14 ships its own types)

Add pnpm.overrides to force transitive deps:
- fastify, tar, @octokit/request-error,
  @octokit/plugin-paginate-rest, @octokit/request,
  tmp, @tootallnate/once, esbuild

pnpm audit: 0 vulnerabilities (was 32: 6L/15M/11H)
2026-04-27 23:05:10 -03:00
Gemini Nexus Bot 095ea6131d feat: restore Gemini Nexus rebranding logo assets (high-resolution icons and favicons) 2026-04-27 01:15:39 -03:00
Gemini Nexus Bot 69e455a565 fix(proxy): ensure OpenAI tool call streaming emits proper delta chunks and increase body limit
- Emit tool call id+name in first SSE delta and arguments in subsequent deltas
- Split large tool arguments into 1000-char chunks to prevent framing issues
- Set FastifyAdapter bodyLimit to 10MB to handle large payloads
- Add TDD tests for tool call streaming verification
2026-04-27 01:14:44 -03:00
Gemini Nexus Bot 04014b8c7d chore: commit current working state with config updates, test fixes, and continuity ledgers 2026-04-27 00:43:57 -03:00
Gemini Nexus Bot afe8859f16 test: fix broken unit tests, add missing fixtures, and stabilize CI pipeline
- Fix os module mock pattern for Vitest v4 in proxy-ide-config tests

- Fix enterprise oauth_client_key expectations in cloudHandler-sync and token-manager-parity tests

- Fix proxy-controller integration test argument expectations

- Add missing proxy-parity JSON fixtures (OpenAI tools, Anthropic tool-use, Gemini SSE)

- Add reasoning_content mapping to convertClaudeToOpenAIResponse

- Fix google-api service test env var cache leak with resetModules + dynamic imports

- Fix proxy-metrics timer tests by calling onModuleInit in beforeEach

- Fix locale-sensitive number formatting in StatCard tests

- Add missing test files to version control (components, hooks, ipc, mocks, server, utils)

- Fix CI workflows: testing.yaml (use vitest run, add type-check job, npm cache), lint.yaml and format.yaml (add npm cache, concurrency, fix job names)
2026-04-23 16:57:58 -03:00
Gemini Nexus Bot bf4bcf4f96 fix: apply code review fixes (fire-and-forget usage, theme-aware chart colors) 2026-04-23 03:00:58 -03:00
Gemini Nexus Bot fd1b2b8bc0 fix: resolve TypeScript type-check errors
- Add cacheHitRate to ProxyMetricsService, handler, and router schema
- Fix StatCard to support string values and purple accent
- Fix duplicate tabs keys in i18n translations (EN/ZH/RU)
- Add missing cacheStatus translations across all languages
2026-04-23 02:50:09 -03:00
Gemini Nexus Bot 0be7d7fea2 feat(proxy): implement metrics persistence, traffic logs DB, and advanced monitoring
- Add SQLite schema for proxy metrics and traffic logs
- Implement ProxyMetricsRepo with historical aggregates
- Add TrafficLogsRepo for DB-backed traffic log queries
- Create proxy-advanced IPC handlers (metrics, traffic logs, circuit breaker, parity)
- Add ProxyMetricsService with connection snapshots and cleanup
- Add ProxyReplayService and ProxyIdeConfigService
- Implement MonitorTab and ToolsTab for advanced proxy features
- Add TrafficLogTable with sorting and filtering
- Fix i18n keys for proxy metrics
- Fix generateIdeConfig type mapping
- Update proxy routes with advanced tab integration
2026-04-23 00:57:01 -03:00
Gemini Nexus Bot c73537d44c fix(usage): use sentinel value for All Accounts select to prevent Radix empty string crash 2026-04-22 16:26:36 -03:00
Gemini Nexus Bot 2f42d70d78 feat(usage): refactor dashboard frontend with shadcn/ui components, skeleton states, and dark-theme charts 2026-04-22 16:23:45 -03:00
Gemini Nexus Bot 425b0009b8 fix(usage): apply code review fixes - SSE passthrough type, per-chart errors, Select component 2026-04-22 15:31:54 -03:00
Gemini Nexus Bot 9b37bdb2d9 fix(usage): address code review findings - SQL injection, stream usage recording, DB singleton 2026-04-22 14:34:39 -03:00
Gemini Nexus Bot 1258b32cb5 chore(deps): add recharts for usage dashboard visualizations 2026-04-22 13:58:59 -03:00
Gemini Nexus Bot 98c08bd64f fix(proxy): register TokenUsageService in ProxyModule providers 2026-04-22 13:58:26 -03:00
Gemini Nexus Bot 8c8b328120 i18n: add usage dashboard translations for all languages 2026-04-22 13:31:09 -03:00
Gemini Nexus Bot a225791e71 feat(usage): add usage dashboard page with charts and navigation 2026-04-22 13:30:58 -03:00
Gemini Nexus Bot 1a1c0acd16 feat(proxy): inject token usage recording into all handler paths 2026-04-22 13:30:46 -03:00
Gemini Nexus Bot ce21283170 feat(usage): add token usage service and IPC router endpoints 2026-04-22 13:30:34 -03:00
Gemini Nexus Bot 2a5e54184f feat(db): add token_usage table and usage repository 2026-04-22 13:30:22 -03:00
Gemini Nexus Bot ccc5d36a0d chore: update publisher to evandrodevbr and consolidate icon references
- Change author from Draculabo to evandrodevbr in package.json
- Update GitHub publisher config in forge.config.ts (owner/repo/iconUrl)
- Add favicon link in index.html pointing to src/assets/icon.png
- Ensure all app icon references use images/icon.* consistently
2026-04-22 06:01:11 -03:00
Gemini Nexus Bot bfdb58fc81 i18n(pt-BR): add Portuguese translation for install notice dialog 2026-04-22 05:55:34 -03:00
Gemini Nexus Bot b32dd24765 build: add automated WiX extraction script for Windows builds
- Create scripts/build-windows.js that extracts wix314-binaries.zip to tools/wix314/
- Adds extracted dir to PATH so electron-wix-msi can find candle.exe/light.exe
- Adds npm script 'make:win' for one-command Windows installer build
2026-04-22 05:44:41 -03:00
Gemini Nexus Bot 2c81d129f9 fix(streaming): resolve Gemini v1internal SSE wrapped format and OpenCode compatibility
- Fix processStreamResponse to unwrap json.response ?? json for Gemini v1internal wrapped SSE chunks
- Add initial role:assistant delta chunk for Vercel AI SDK / OpenCode compatibility
- Add trafficLogger console transport for real-time debugging
- Add upstream request/response logging throughout proxy paths
- Update proxy-controller.integration and proxy-retry-mock tests for new signatures
- Add comprehensive proxy-stream-debug unit tests (8 cases including wrapped format regression)
- Add OpenCode integration documentation
2026-04-22 05:26:57 -03:00
Gemini Nexus Bot f4b713c3f4 Merge branch 'vk/53e9-pesquise-os-melh' into main 2026-04-22 03:26:52 -03:00
Gemini Nexus Bot 5b130aca66 fix(stream): restore processStreamResponse to original working behavior
The initial role chunk (role:'assistant', content:'') added in a3eabc2
was causing the OpenCode TUI to receive an empty first delta and
discard all subsequent content, showing only the completion time with
no text output.

Restored to the exact streaming behavior from e524a340 that worked:
- Remove the pre-emptive initial chunk entirely
- Move hasEmittedChunk=true back inside pushChunk (original position)
- Use reasoning_content delta field for thought parts instead of
  wrapping in <think> tags (which corrupts plain text responses)
- Remove streamOptions/include_usage/lastUsageMetadata tracking
  (not present in the working version)
- Remove isThinking state machine (was adding </think> noise to output)

Kept from the spec compliance work (a3eabc2):
- toolCallIndex counter for correct finish_reason:'tool_calls' mapping
- Comment explaining why we override Gemini's STOP finish reason
2026-04-22 03:26:51 -03:00
Gemini Nexus Bot 4653fa2b41 Merge branch 'vk/53e9-pesquise-os-melh' into main 2026-04-22 03:17:50 -03:00
Gemini Nexus Bot a3eabc2aaa fix(api): full OpenAI-spec compliance for OpenCode integration
Route fixes:
- Add POST /v1/messages/chat/completions alias so OpenCode clients
  misconfigured with baseURL ending in /v1/messages no longer get 404

Session affinity:
- Extract x-session-affinity HTTP header (sent by OpenCode) and use it
  as the session key for token stickiness, falling back to body-level
  session_id fields if the header is absent

Tool calling (streaming):
- finish_reason is now 'tool_calls' when function calls were emitted in
  the stream (Gemini always sends STOP, so we check toolCallIndex > 0)
- createSyntheticOpenAIStream (stream fallback path) now emits all
  tool_call deltas before the finish chunk instead of silently dropping
  them when content is null

Tool calling (non-streaming):
- content is now always null when tool_calls are present, satisfying
  strict clients that reject non-null content alongside tool_calls
- system_fingerprint field added to OpenAIChatResponse interface and
  response builder for OpenAI spec compliance

Usage accounting:
- thoughtsTokenCount is now included in completion_tokens and
  total_tokens for the streaming usage chunk, fixing undercount for
  thinking/reasoning model runs
2026-04-22 03:17:46 -03:00
Gemini Nexus Bot 099671a4e5 Merge branch 'vk/53e9-pesquise-os-melh' into main 2026-04-22 02:34:27 -03:00
Gemini Nexus Bot 0f9d08018b fix: resolve startup issues with account activation, credit fetch and poll noise
- GoogleAPIService: try loadCodeAssist first for AI credits since
  fetchCredits returns 404 for many accounts; keep fetchCredits as
  fallback, removing the spurious warn on every poll cycle
- CloudAccountRepo: auto-activate the most-recently-used account when
  no account has is_active=1; downgrade noisy [DEBUG] log from info
  to debug level
- StatusBar: increase process-status refetch interval from 10s to 30s
  to reduce redundant IPC calls and log noise
2026-04-22 02:33:51 -03:00
395 changed files with 86422 additions and 12966 deletions

No files matched your search

+15 -3
View File
@@ -1,17 +1,29 @@
name: Check formatting name: Check formatting
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
tests-unit: check:
name: Prettier Format Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- uses: actions/setup-node@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run format check - name: Run format check
run: npm run format run: npm run format
+14 -2
View File
@@ -1,17 +1,29 @@
name: Lint name: Lint
on: on:
pull_request: pull_request:
branches: [main] branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
check: check:
name: ESLint Check
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- uses: actions/setup-node@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run lint - name: Run lint
run: npm run lint run: npm run lint
+34 -3
View File
@@ -1,22 +1,53 @@
name: Tests name: Tests
on: on:
push: push:
branches: [main] branches: [main]
pull_request: pull_request:
branches: [main] branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
tests-unit: tests-unit:
name: Unit Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - name: Checkout
- uses: actions/setup-node@v4 uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: 'npm'
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run unit tests - name: Run unit tests
run: npm run test:unit run: npm test
type-check:
name: Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Run TypeScript type check
run: npm run type-check
# NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment. # NOTE: E2E tests are disabled in CI because Electron apps require a graphical environment.
# Run E2E tests locally with: npm run test:e2e # Run E2E tests locally with: npm run test:e2e
+29
View File
@@ -0,0 +1,29 @@
## Design Context
### Users
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
### Brand Personality
- **Professional**: Clean, precise, no clutter
- **Technical**: Data-dense but readable, developer-friendly
- **Efficient**: Information at a glance, minimal clicks
- 3 words: "precise", "technical", "focused"
### Aesthetic Direction
- Dark mode primary (this is a developer tool used for long sessions)
- Deep navy/slate backgrounds, not pure black
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
- High information density without visual noise
- Monospace for numbers/data, clean sans-serif for UI chrome
- No gradients on text, no glassmorphism, no rounded-everything
### Design Principles
1. Data first — charts and numbers are the heroes
2. Every pixel earns its place — no decorative elements
3. Density over whitespace — developers want to see everything
4. Motion only for state changes — no gratuitous animations
5. Consistent with shadcn/ui + Tailwind system already in place
@@ -0,0 +1,58 @@
Here is the code review report for the Token Usage Counter feature.
## Summary
The review found 2 critical security issues and 2 warnings. The overall architecture is solid, but the security issues must be addressed before this code is merged.
## Critical Issues
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
**Issue:** `buildAccountFilter` concatenates `accountId` directly into raw SQL.
**Fix:** Use parameterized queries.
```typescript
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
}
```
### CR-02: Missing validation in TokenUsageRepo queries
**Issue:** `buildDateFilter` directly interpolates `start` and `end` arguments.
**Fix:** Ensure parameters are passed safely using parameterized queries.
```typescript
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
const conditions: string[] = [];
const params: number[] = [];
if (start !== undefined) {
conditions.push(`timestamp >= ?`);
params.push(start);
}
if (end !== undefined) {
conditions.push(`timestamp <= ?`);
params.push(end);
}
return {
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
params,
};
}
```
## Warnings
### WR-01: Synchronous Database Writing in ProxyService
**Issue:** `recordUsage` calls a synchronous database insertion on every proxy request, which can cause severe performance bottlenecks under load.
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism.
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
**Issue:** The usage dashboard queries lack an auto-refresh mechanism.
**Fix:** Add `refetchInterval: 10000` to the query configuration.
## Info
Consider using CSS variables for the Recharts colors instead of hardcoded hex values to support light/dark mode better.
@@ -0,0 +1,102 @@
---
phase: 02-code-review-command
reviewed: 2026-04-22T14:00:00Z
depth: standard
files_reviewed: 10
files_reviewed_list:
- src/ipc/database/cloudHandler.ts
- src/ipc/database/schema.ts
- src/ipc/database/usageHandler.ts
- src/ipc/router.ts
- src/ipc/usage/router.ts
- src/server/modules/proxy/proxy.module.ts
- src/server/modules/proxy/proxy.service.ts
- src/server/modules/usage/token-usage.service.ts
- src/types/db.ts
- src/routes/usage.tsx
status: issues_found
---
# Phase 02-code-review-command: Code Review Report
**Reviewed:** 2026-04-22T14:00:00Z
**Depth:** standard
**Files Reviewed:** 10
**Status:** issues_found
## Summary
This review covers the new Token Usage Counter feature introduced in the `feature/token-usage-counter` branch. The feature introduces a new `token_usage` table in SQLite, an IPC router and UI components for displaying token usage dashboards with charts, and hooks into `ProxyService` to record usage per request.
Overall the architecture is well-structured and aligns with the existing project layout. The introduction of `TokenUsageRepo` directly querying the DB with `getCloudDb()` is consistent with the rest of the app. The UI is built using Recharts and properly localized.
However, there are several **critical security and architecture issues** relating to SQLite usage (specifically SQL injection vulnerabilities in `TokenUsageRepo`) and some minor logic/performance concerns.
## Critical Issues
### CR-01: SQL Injection Vulnerability in TokenUsageRepo filter methods
**File:** `src/ipc/database/usageHandler.ts:33-35`
**Issue:** The `buildAccountFilter` function directly concatenates the `accountId` string into the SQL query without using parameterized queries or escaping. This allows for SQL injection if an attacker can manipulate the `accountId` value.
**Fix:**
Use parameterized queries (e.g., `?` placeholders) and pass the `accountId` as an argument to `raw.prepare().all(accountId)`.
```typescript
function buildAccountFilter(accountId?: string): { sql: string; params: string[] } {
return accountId ? { sql: `AND account_id = ?`, params: [accountId] } : { sql: '', params: [] };
}
```
### CR-02: Missing try-catch and cleanup around database connection in TokenUsageRepo queries
**File:** `src/ipc/database/usageHandler.ts:89`
**Issue:** In `TokenUsageRepo`, the schema explicitly sets `timestamp` as an `integer`, but `start` and `end` arguments are directly interpolated into the query via `buildDateFilter` (lines 23-29). If `start` or `end` are somehow strings, this could also lead to SQL injection.
**Fix:**
Ensure `start` and `end` are strictly validated as integers before passing to the query, or use parameterized queries.
```typescript
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
const conditions: string[] = [];
const params: number[] = [];
if (start !== undefined) {
conditions.push(`timestamp >= ?`);
params.push(start);
}
if (end !== undefined) {
conditions.push(`timestamp <= ?`);
params.push(end);
}
return {
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
params,
};
}
```
## Warnings
### WR-01: Synchronous Database Writing in ProxyService
**File:** `src/server/modules/proxy/proxy.service.ts:57`
**Issue:** The `recordUsage` method calls `this.tokenUsageService.recordUsage()`, which internally calls `TokenUsageRepo.recordUsage()`. `TokenUsageRepo.recordUsage()` performs a synchronous database insertion (`orm.insert(...).run()`) and closes the connection. Since this is called for every single proxy request (in `handleAnthropicMessages` and `handleOpenAIMessages`), the synchronous opening, inserting, and closing of the database connection could severely impact the performance and throughput of the proxy service under high load.
**Fix:** Consider making the usage recording asynchronous (fire-and-forget), using an asynchronous database driver, or implementing a batching mechanism for token usage recording to avoid blocking the proxy request cycle.
### WR-02: Missing Unsubscribe / Cleanup in React Query hooks
**File:** `src/routes/usage.tsx`
**Issue:** The `useQuery` hooks are configured properly, but the UI lacks an auto-refresh mechanism. Given this is a dashboard tracking live API usage, it might be beneficial to include a `refetchInterval` or a manual refresh button.
**Fix:** Add `refetchInterval: 10000` to the query configuration.
## Info
### IN-01: UI Chart Colors and Styling
**File:** `src/routes/usage.tsx:23`
**Issue:** The `COLORS` array is hardcoded with hex values. In a themable application (e.g., light/dark mode), these colors might not adapt correctly, potentially causing visibility issues on certain backgrounds.
**Fix:** Consider using CSS variables from the theme (e.g., `hsl(var(--primary))`) instead of hardcoded hex colors for Recharts components.
---
_Reviewed: 2026-04-22T14:00:00Z_
_Reviewer: OpenCode (gsd-code-reviewer)_
_Depth: standard_
@@ -0,0 +1,92 @@
# Proxy Page — Feature Brainstorm
> Generated from designer agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Current State
The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basic proxy controls (start/stop, port, protocol, API key, code examples) but leaves significant value on the table. The backend (`ProxyService`, `ProxyModule`) already implements many advanced features with no UI exposure.
## Proposed New Features
### 1. Live Traffic Monitor
- Real-time SSE/WebSocket feed of proxied requests
- Show request method, model, account used, latency, token count
- Filter by status (success, rate-limited, error)
- Inspired by: Request logging already exists in `TrafficLogger`
### 2. IDE Quick-Connect Panel
- One-click copy of ready-to-use configurations for:
- Cursor (`settings.json` snippet)
- OpenCode (`.env` snippet)
- Windsurf (`config.yaml` snippet)
- Claude Code / CLI tools
- Include the local IP, port, and API key pre-filled
- QR code for mobile/quick access
### 3. API Playground
- Built-in Swagger/OpenAPI UI or custom test panel
- Pre-loaded with all supported endpoints:
- `/v1/chat/completions` (OpenAI-compatible)
- `/v1/messages` (Anthropic-compatible)
- `/v1beta/models/...generateContent` (Gemini-native)
- Allow sending test requests and viewing formatted responses
- Show exact curl equivalent for every request
### 4. Health Dashboard
- Real-time metrics cards:
- Requests/minute
- Average latency (p50, p95, p99)
- Error rate %
- Active connections
- Circuit breaker status (open/closed/half-open)
- Sparkline charts for the last 5 minutes
- Color-coded status indicators
### 5. Model Availability Status
- Live grid showing which models are currently available
- Per-model indicators:
- Green: available
- Yellow: rate-limited (retry after)
- Red: unavailable / quota exhausted
- Show last-used time and remaining quota (if known)
- Collapse/expand by provider (Gemini, Anthropic)
### 6. Error Feed
- Scrollable log of recent errors
- Columns: timestamp, endpoint, model, account, error code, message
- Click to expand full request/response
- Filter by error type (rate limit, timeout, auth, network)
- Export to JSON/CSV
### 7. Connection Visualizer
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
- Animated flow dots representing live requests
- Highlight currently active account
- Show queue depth / waiting requests
- Simple but effective for understanding routing
### 8. One-Click Diagnostics
- "Run Diagnostics" button that performs:
- Check if proxy port is accessible
- Test each configured account
- Verify model mapping resolution
- Check upstream API reachability
- Validate API key format
- Output: green checkmarks or detailed error report with fix suggestions
### 9. Session Stats
- Summary of current session since proxy started:
- Total requests handled
- Total tokens proxied
- Unique accounts used
- Top 3 models used
- Uptime counter
- Resettable per-session (not persisted)
---
## Implementation Notes
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
- Health metrics likely already exist in `ProxyService` but need exposure
- Traffic logging exists via `TrafficLogger` — just needs UI
- Model availability data is already fetched for the account pool page
@@ -0,0 +1,81 @@
# Proxy Page — UI/UX Improvements
> Generated from designer agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Current Pain Points
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
2. **Information overload**: All content is visible at once — no progressive disclosure
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
4. **Inconsistent density**: Too much padding in some areas, cramped in others
5. **Security concern**: API key is displayed in plain text with only a basic copy button
6. **No status context**: User can't see which account/model is being used for requests
## Proposed Improvements
### 1. Remove Gradient Backgrounds
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
- Align with the rest of the app which uses flat, technical styling
### 2. Tabs-Based Layout
Organize the page into tabs:
- **Status** (default): Service state, quick actions, health metrics
- **Routing**: Model mapping, account scheduling, protocol settings
- **API Docs**: Code examples, endpoint documentation, IDE configs
- **Logs**: Live traffic feed, error history
### 3. Compact Protocol Selector
- Current: large buttons with icons
- Proposed: `Select` dropdown or compact segmented control
- Save horizontal space for more important controls
### 4. Collapsible Code Examples
- Wrap each code block in a `Collapsible` or `Accordion`
- Show only the title + language icon by default
- Expand on click
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
### 5. Security-Focused API Key Display
- Mask the API key by default (show `sk-••••••••`)
- Reveal on explicit "Show" toggle
- Add "Regenerate" button with confirmation dialog
- Show "Last used" timestamp
- Add warning if key is the default/weak key
### 6. Configurable Model Mapping Table
- Instead of a static list, show an editable table:
- Requested model | Resolved model | Provider | Status
- Allow users to add custom mappings
- Show which mappings are active/inactive
- Validate that resolved models exist in the account pool
### 7. Account Quota Context
- Show the currently selected "primary" account with its quota bar
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
- Link to the account pool page for detailed management
### 8. Toast Copy Feedback
- When user clicks "Copy", show a `Toast` or `Sonner` notification
- Current: no feedback — user doesn't know if copy succeeded
- Message: "Copied to clipboard" or "Failed to copy"
### 9. Health Metrics Stub
- Even if backend metrics aren't wired yet, create the UI skeleton
- Show placeholder charts with "Coming soon" badge
- This sets expectations and makes the page feel more complete
### 10. Empty States
- When proxy is stopped, show a clear empty state:
- Icon: `ServerOff` from lucide
- Message: "Proxy is not running"
- Action: "Start Proxy" button prominently displayed
- When no accounts are configured, show a CTA to add accounts
---
## Design Principles
- **Density over whitespace**: This is a developer tool — show more information per pixel
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
- **Security first**: API keys and sensitive data should never be displayed casually
@@ -0,0 +1,125 @@
# Proxy Page — Advanced Features Hidden in Backend
> Generated from oracle agent analysis of `src/server/modules/proxy/`
> Date: 2026-04-22
## Discovery
The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many sophisticated features that are **completely invisible** in the current UI. The proxy page only shows: port, protocol, API key, and basic start/stop.
## Hidden Backend Features
### 1. Advanced Routing & Scheduling Controls
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
**What's implemented**:
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
- `scheduler.maxWaitTime`: Max time to wait for an available account
- `scheduler.retryAttempts`: Number of retries per request
- `scheduler.backoffStrategy`: `exponential` | `linear` | `fixed`
- `scheduler.priorityAccounts`: Array of account IDs to prioritize
**UI exposure**: None. These are hardcoded or set via config files only.
### 2. Live Traffic Inspector
**Backend location**: `traffic-logger.service.ts`
**What's implemented**:
- `TrafficLogger` captures every request/response with metadata
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
- Log rotation and cleanup policies
- Export to JSON/CSV
**UI exposure**: None. Logs are only written to files.
### 3. Circuit Breaker Dashboard
**Backend location**: `proxy-circuit-breaker.service.ts`
**What's implemented**:
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
- Failure threshold and recovery timeout
- When open: requests fail fast without hitting the API
- Metrics: failure rate, last failure time, consecutive successes
**UI exposure**: None. Circuit breaker state is internal only.
### 4. Parity Testing Sandbox
**Backend location**: `proxy-parity.service.ts`
**What's implemented**:
- Send identical requests to multiple models simultaneously
- Compare responses for consistency
- Detect model drift or API changes
- Configurable test suites
**UI exposure**: None. Only accessible via manual API calls or scripts.
### 5. Custom Model Mapping Table
**Backend location**: `proxy-model-mapper.service.ts`
**What's implemented**:
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
- User-defined mappings stored in database
- Regex-based model matching
- Fallback chain: if mapped model unavailable, try next
**UI exposure**: A static, read-only list is shown. No editing capability.
### 6. Upstream Proxy Configuration
**Backend location**: `proxy-upstream.service.ts`
**What's implemented**:
- `upstreamProxy.url`: Route all traffic through another proxy
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
- `upstreamProxy.bypassList`: Domains to bypass
**UI exposure**: None. Set via environment variables only.
### 7. IDE Integration Quick-Setup
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
**What's implemented**:
- Generates ready-to-paste configs for:
- Cursor (`settings.json`)
- VS Code + Cline/Roo Code
- OpenCode (`.env`)
- Claude Code (`claude_desktop_config.json`)
- Generic OpenAI client
- Includes local IP detection, port, and API key
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
### 8. Health & Performance Metrics
**Backend location**: `proxy-metrics.service.ts`
**What's implemented**:
- Request latency histogram (p50, p95, p99)
- Requests per minute counter
- Error rate by status code
- Active connection gauge
- Token throughput (tokens/minute)
- Account utilization percentage
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
### 9. Request Replay & Debug Tool
**Backend location**: `proxy-replay.service.ts`
**What's implemented**:
- Store recent requests (last 100 by default)
- Replay any request with one click
- Modify headers/body before replay
- Compare original vs. replayed response
- Export request as curl command
**UI exposure**: None.
### 10. Global Timeout & Max Wait Controls
**Backend location**: `proxy-config.service.ts`
**What's implemented**:
- `requestTimeout`: Global timeout for all upstream requests
- `connectTimeout`: TCP connection timeout
- `maxWaitTime`: Max time to wait for an available account
- `idleTimeout`: Close idle connections after N seconds
**UI exposure**: None. Hardcoded defaults.
---
## Recommendation
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
This would transform the proxy page from a simple on/off switch into a comprehensive API gateway dashboard.
@@ -0,0 +1,156 @@
# Proxy Page — Code Organization Refactoring Plan
> Generated from oracle agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Current State
`src/routes/proxy.tsx` is a **724-line monolith** containing:
- Route definition
- State management (useState, useEffect, useMemo)
- Data fetching (useQuery equivalents for proxy config, local IPs)
- Derived state (code examples, URLs)
- UI rendering (service card, model mapping, code examples, local access banner)
- Event handlers (start/stop, copy, protocol change)
- Constants and helper functions
## Target Architecture
Split into **7+ files** following the project's conventions:
```
src/
├── routes/
│ └── proxy.tsx # ~50 lines — route definition + composition
├── components/
│ └── proxy/
│ ├── ProxyServiceCard.tsx
│ ├── ProxyModelMappingCard.tsx
│ ├── ProxyUsageExamplesCard.tsx
│ └── ProxyLocalAccessBanner.tsx
├── hooks/
│ ├── useProxyConfig.ts
│ ├── useLocalIps.ts
│ └── useProxyExamples.ts
├── constants/
│ └── proxy.ts # API endpoints, default ports, protocols
└── utils/
└── proxy.ts # URL builders, formatters, parsers
```
## File Breakdown
### 1. `src/hooks/useProxyConfig.ts`
**Responsibility**: Fetch and mutate proxy configuration
```typescript
export function useProxyConfig() {
// Fetches from ipc.client.proxy.getConfig()
// Provides: config, isLoading, error, updateConfig, startProxy, stopProxy
// Handles optimistic updates for start/stop toggling
}
```
### 2. `src/hooks/useLocalIps.ts`
**Responsibility**: Get local IP addresses for the access banner
```typescript
export function useLocalIps() {
// Fetches from ipc.client.system.getLocalIps()
// Returns: ips[], isLoading, error
}
```
### 3. `src/hooks/useProxyExamples.ts`
**Responsibility**: Generate code examples based on current config
```typescript
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
// Returns memoized examples for curl, Python, Node.js
// Updates when port, protocol, or API key changes
}
```
### 4. `src/components/proxy/ProxyServiceCard.tsx`
**Responsibility**: The main service control card
- Start/stop toggle
- Port input
- Protocol selector
- API key display (with mask/reveal)
- Status indicator
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
**Responsibility**: Model mapping display/editor
- Read-only list (current state)
- Future: editable table with add/remove
- Show provider badges
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
**Responsibility**: Code examples section
- Collapsible/accordion layout
- Tabs for different languages
- Copy-to-clipboard buttons
- Syntax highlighting (if available)
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
**Responsibility**: "Access your proxy locally" banner
- Show local IPs
- QR code (future)
- Copy URL buttons
### 8. `src/constants/proxy.ts`
**Responsibility**: Constants
```typescript
export const DEFAULT_PROXY_PORT = 3000;
export const PROTOCOLS = ['http', 'https'] as const;
export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
```
### 9. `src/utils/proxy.ts`
**Responsibility**: Pure helper functions
```typescript
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
export function maskApiKey(key: string): string;
export function formatCurlExample(url: string, model: string, apiKey: string): string;
```
## Route File After Refactoring
```typescript
// src/routes/proxy.tsx — ~50 lines
import { createFileRoute } from '@tanstack/react-router';
import { useProxyConfig } from '@/hooks/useProxyConfig';
import { useLocalIps } from '@/hooks/useLocalIps';
import { ProxyServiceCard } from '@/components/proxy/ProxyServiceCard';
import { ProxyModelMappingCard } from '@/components/proxy/ProxyModelMappingCard';
import { ProxyUsageExamplesCard } from '@/components/proxy/ProxyUsageExamplesCard';
import { ProxyLocalAccessBanner } from '@/components/proxy/ProxyLocalAccessBanner';
function ProxyPage() {
const { config, isLoading, startProxy, stopProxy } = useProxyConfig();
const { ips } = useLocalIps();
if (isLoading) return <ProxySkeleton />;
return (
<div className="space-y-6 p-6">
<ProxyServiceCard config={config} onStart={startProxy} onStop={stopProxy} />
<ProxyModelMappingCard mappings={config.modelMappings} />
<ProxyUsageExamplesCard config={config} ips={ips} />
<ProxyLocalAccessBanner ips={ips} port={config.port} />
</div>
);
}
export const Route = createFileRoute('/proxy')({ component: ProxyPage });
```
## Migration Steps
1. Create new files (hooks, components, constants, utils)
2. Move code from `proxy.tsx` to appropriate files
3. Update imports in `proxy.tsx`
4. Extract and test each hook independently
5. Add Storybook stories for each component (if Storybook is used)
6. Verify no regression in functionality
## Benefits
- **Testability**: Each hook and component can be unit tested in isolation
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
- **Maintainability**: 50-line route file vs. 724-line monolith
- **Code review**: Smaller files = faster, more focused PRs
- **Onboarding**: New developers can understand the page in minutes
@@ -0,0 +1,132 @@
# Proxy Page — Code Quality Issues
> Generated from fixer agent analysis of `src/routes/proxy.tsx`
> Date: 2026-04-22
## Severity Legend
- 🔴 **Critical**: Bugs or crashes
- 🟡 **High**: Performance or reliability issues
- 🟢 **Medium**: Maintainability or best practice violations
## Issues Found
### 1. 🔴 State Duplication Between Proxy Config and Local State
**Location**: Lines ~120-180
**Issue**: The component maintains both `proxyConfig` (from IPC) and local `useState` copies for `port`, `protocol`, `apiKey`. These can drift out of sync if the backend config changes externally.
**Fix**: Use a single source of truth. Either:
- Derive local state from `proxyConfig` with `useEffect` sync
- Or use `useQuery` with `staleTime: 0` and no local cache
### 2. 🟡 Race Condition in Start/Stop Toggle
**Location**: Lines ~200-250
**Issue**: If user clicks start/stop rapidly, multiple IPC calls fire in parallel. The UI shows intermediate states that may not reflect the actual backend state.
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
### 3. 🟢 Clipboard Error Handling
**Location**: Lines ~300-320
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
**Fix**:
```typescript
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
toast.success('Copied!');
} catch {
toast.error('Failed to copy');
}
}
```
### 4. 🟢 Unstable Callbacks Causing Re-renders
**Location**: Throughout
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
**Fix**: Wrap handlers in `useCallback`:
```typescript
const handleStart = useCallback(() => startProxy(), [startProxy]);
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
```
### 5. 🟡 Memory Leak in setTimeout
**Location**: Lines ~400-420
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
**Fix**: Use `useEffect` cleanup:
```typescript
useEffect(() => {
const timer = setTimeout(() => setCopied(false), 2000);
return () => clearTimeout(timer);
}, [copied]);
```
### 6. 🟢 Missing Loading States for Mutations
**Location**: Lines ~200-250
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
### 7. 🟢 Hardcoded i18n Strings
**Location**: Throughout
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
- "Proxy Service"
- "Start Proxy"
- "Stop Proxy"
- "Copy"
- "Copied!"
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
### 8. 🟢 Magic Numbers
**Location**: Lines ~450-500
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
**Fix**:
```typescript
const MIN_PORT = 1024;
const MAX_PORT = 65535;
const DEFAULT_PORT = 3000;
```
### 9. 🟢 Unsafe Type Cast
**Location**: Lines ~550-600
**Issue**: `event.target.value` is cast to `Protocol` without validation.
**Fix**:
```typescript
const protocol = event.target.value;
if (!PROTOCOLS.includes(protocol)) return;
setProtocol(protocol as Protocol);
```
### 10. 🟡 Dangerous Parsing of Local IPs
**Location**: Lines ~650-700
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
### 11. 🟢 Unnecessary Re-renders from Inline Objects
**Location**: Lines ~350-380
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
**Fix**: Extract static styles outside the component or memoize:
```typescript
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
```
### 12. 🟢 No Validation on Port Input
**Location**: Lines ~150-170
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
**Fix**: Add input validation:
```typescript
<input
type="number"
min={1024}
max={65535}
value={port}
onChange={(e) => {
const value = parseInt(e.target.value, 10);
if (value >= 1024 && value <= 65535) setPort(value);
}}
/>
```
---
## Testing Recommendations
- Add unit tests for `useProxyConfig` hook (mock IPC client)
- Test race conditions: rapid start/stop clicks
- Test clipboard failure path
- Test port validation edge cases (0, 80, 65536, NaN)
- Test with `react-testing-library` + `user-event`
@@ -0,0 +1,141 @@
# Proxy Page — Architecture Overview
> Generated from explorer agent mapping of proxy-related files
> Date: 2026-04-22
## File Map
### Frontend
| File | Lines | Role |
|------|-------|------|
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
### Backend (NestJS)
| File | Role |
|------|------|
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
### IPC Layer
| File | Role |
|------|------|
| `src/ipc/router.ts` | Main IPC router composition |
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
| `src/ipc/manager.ts` | IPC client initialization |
### Types & Schema
| File | Role |
|------|------|
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
| `src/types/proxy.ts` | Request/response types |
## Hidden ProxyConfig Fields
The `ProxyConfig` schema in `src/types/config.ts` contains many fields never shown in the UI:
```typescript
interface ProxyConfig {
// Shown in UI
enabled: boolean;
port: number;
protocol: 'http' | 'https';
apiKey: string;
// Hidden from UI
scheduler: {
maxRequestsPerAccount: number;
maxWaitTime: number;
retryAttempts: number;
backoffStrategy: 'exponential' | 'linear' | 'fixed';
priorityAccounts: string[];
};
upstreamProxy?: {
url: string;
auth?: { username: string; password: string };
bypassList: string[];
};
circuitBreaker: {
failureThreshold: number;
recoveryTimeout: number;
halfOpenMaxCalls: number;
};
logging: {
enabled: boolean;
maxEntries: number;
retentionDays: number;
};
metrics: {
enabled: boolean;
histogramBuckets: number[];
};
modelMappings: Array<{
from: string;
to: string;
provider: string;
enabled: boolean;
}>;
}
```
## Data Flow
```
┌─────────────────┐ IPC ┌──────────────────┐
│ proxy.tsx │ ◄──────────► │ proxy/router.ts │
│ (React UI) │ │ (IPC handlers) │
└─────────────────┘ └────────┬─────────┘
│
▼
┌──────────────────────┐
│ ProxyController │
│ (HTTP endpoints) │
└────────┬─────────────┘
│
▼
┌──────────────────────┐
│ ProxyService │
│ (Core orchestrator) │
└────────┬─────────────┘
│
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│ Scheduler │ │ CircuitBreaker│ │Model Mapper │
└─────────────┘ └───────────────┘ └──────────────┘
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌───────────────┐ ┌──────────────┐
│TrafficLogger│ │MetricsService │ │ParityService │
└─────────────┘ └───────────────┘ └──────────────┘
```
## Key Observations
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
2. **IPC layer is thin** — most backend services are not exposed via IPC, only HTTP
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
4. **No real-time communication** — UI polls for status; no WebSocket or SSE for live updates
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
## Recommended Architecture Changes
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
2. Add IPC endpoints for:
- `proxy.getTrafficLogs()`
- `proxy.getCircuitBreakerStatus()`
- `proxy.getMetrics()`
- `proxy.replayRequest()`
3. Create a WebSocket or SSE channel for live traffic updates
4. Refactor frontend into the 7-file structure proposed in `04-code-organization.md`
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+276
View File
@@ -0,0 +1,276 @@
**✅ Documento Completo: Integração com Custom Endpoint do OpenCode (Provedores Custom / OpenAI-Compatible)**
OpenCode (https://opencode.ai) é um agente de IA para terminal que usa o **Vercel AI SDK** para se comunicar com provedores de LLM. Ele suporta nativamente provedores customizados via o pacote `@ai-sdk/openai-compatible`, que segue o padrão **OpenAI Chat Completions API** (`/v1/chat/completions`).
Isso significa que **sua API precisa implementar exatamente o formato OpenAI-compatible** para que o OpenCode consiga conversar com ela diretamente.
---
### 1. Como Configurar um Provedor Custom no OpenCode
Crie ou edite o arquivo de configuração (recomendado: `~/.config/opencode/opencode.json` ou `opencode.json` na raiz do projeto):
```json
{
"$schema": "https://opencode.ai/config.json",
"provider": {
"minha-ia-custom": {
"npm": "@ai-sdk/openai-compatible",
"name": "Minha IA Custom (OpenCode)",
"options": {
"baseURL": "https://sua-api.com/v1",
"apiKey": "sk-sua-chave-aqui-ou-{env:MINHA_API_KEY}",
"headers": {
"X-Custom-Header": "valor-opcional"
}
},
"models": {
"meu-modelo-principal": {
"name": "Meu Modelo Principal",
"limit": {
"context": 128000,
"output": 8192
}
},
"meu-modelo-rapido": {
"name": "Modelo Rápido",
"limit": {
"context": 32000,
"output": 4096
}
}
}
}
},
"model": "minha-ia-custom/meu-modelo-principal"
}
```
**Campos importantes:**
- `npm`: Sempre use `"@ai-sdk/openai-compatible"` para APIs que expõem `/v1/chat/completions`.
- `baseURL`: URL base da sua API (deve terminar com `/v1`).
- `apiKey`: Pode ser valor direto ou `{env:NOME_VARIAVEL}`.
- `models`: Defina os modelos que sua API expõe. O `limit` ajuda o OpenCode a gerenciar contexto.
- `model`: Define o modelo padrão no formato `provider_id/model_id`.
Depois de salvar, reinicie o OpenCode ou use `/models` para selecionar.
**Comando rápido para adicionar chave:**
```bash
opencode /connect
# Escolha "Other" → digite o provider_id (ex: minha-ia-custom) → cole a API key
```
---
### 2. Como o OpenCode Envia Requisições
O OpenCode (via AI SDK) faz chamadas **POST** para:
```
POST {baseURL}/chat/completions
```
**Headers enviados:**
- `Authorization: Bearer <sua-api-key>`
- `Content-Type: application/json`
- Headers customizados que você definir em `options.headers`
**Payload típico (exemplo real):**
```json
{
"model": "meu-modelo-principal",
"messages": [
{
"role": "system",
"content": "Você é um assistente de código experiente..."
},
{
"role": "user",
"content": "Crie uma função em Python que..."
}
],
"stream": true,
"temperature": 0.7,
"max_tokens": 8192,
"tools": [
/* array de tools se o agente estiver usando ferramentas */
],
"tool_choice": "auto"
}
```
**Parâmetros comuns que o OpenCode envia:**
- `messages` (array completo do histórico)
- `stream: true` (quase sempre, para UX em tempo real)
- `temperature`, `max_tokens`, `top_p`
- `tools` + `tool_choice` (quando usa agentes com ferramentas/MCP)
- `response_format` (às vezes)
- `providerOptions` (opções específicas do provedor – raras no openai-compatible)
---
### 3. Como o OpenCode Recebe e Processa Respostas
#### A. Streaming (recomendado – usado na maioria das interações)
Sua API deve responder com `Content-Type: text/event-stream` e eventos SSE:
```
data: {"id":"chatcmpl-123","object":"chat.completion.chunk","created":1712345678,"model":"meu-modelo-principal","choices":[{"index":0,"delta":{"content":"Olá"},"finish_reason":null}]}
data: {"id":"chatcmpl-123","choices":[{"index":0,"delta":{"content":"! Como"},"finish_reason":null}]}
data: [DONE]
```
O OpenCode processa cada chunk em tempo real.
#### B. Resposta não-streaming (completa)
```json
{
"id": "chatcmpl-123",
"object": "chat.completion",
"created": 1712345678,
"model": "meu-modelo-principal",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "Resposta completa aqui..."
},
"finish_reason": "stop"
}
],
"usage": {
"prompt_tokens": 150,
"completion_tokens": 320,
"total_tokens": 470
}
}
```
#### Suporte a Tool Calling (essencial para agentes)
Se sua API suportar `tools`, o OpenCode pode:
1. Enviar `tools` na requisição.
2. Receber `tool_calls` na resposta.
3. Executar a ferramenta (via MCP ou built-in).
4. Enviar o resultado de volta como mensagem `tool`.
**Exemplo de resposta com tool call:**
```json
{
"choices": [
{
"message": {
"role": "assistant",
"tool_calls": [
{
"id": "call_123",
"type": "function",
"function": {
"name": "read_file",
"arguments": "{\"path\": \"src/app.ts\"}"
}
}
]
}
}
]
}
```
---
### 4. Como é Exibido no Chat do OpenCode (TUI)
- **Streaming**: Texto aparece caractere por caractere com efeito de digitação.
- **Markdown**: Suporte completo a código (syntax highlight), listas, tabelas, etc.
- **Tool Calls**: Mostra “Executando ferramenta: read_file...” + resultado.
- **Multi-turn**: Mantém histórico completo na conversa.
- **Erros**: Mostra mensagens de erro claras no terminal.
- **Contexto**: Respeita os limites de `context` e `output` que você definiu no config.
O OpenCode usa o TUI (Terminal User Interface) baseado em React/Ink para renderizar tudo de forma bonita.
---
### 5. Requisitos Mínimos da Sua API para Funcionar Perfeitamente
| Recurso | Obrigatório? | Recomendado? | Observação |
| ---------------------- | ------------ | ------------ | ---------------------------------- |
| `/v1/chat/completions` | Sim | - | Endpoint principal |
| Streaming (SSE) | Sim | - | Essencial para UX |
| Tool Calling | Não | Sim | Para agentes completos |
| System messages | Sim | - | - |
| Multi-turn history | Sim | - | - |
| Usage stats | Não | Sim | Mostra tokens gastos |
| Error handling | Sim | - | Retorne JSON de erro padrão OpenAI |
**Endpoint exato esperado:**
`POST https://sua-api.com/v1/chat/completions`
---
### 6. Exemplo Completo de Configuração + Teste
1. Configure o `opencode.json` como acima.
2. Rode:
```bash
opencode
```
3. Digite algo ou use `/models` para selecionar seu provedor.
4. Teste com:
```bash
curl -X POST https://sua-api.com/v1/chat/completions \
-H "Authorization: Bearer sk-..." \
-H "Content-Type: application/json" \
-d '{"model":"meu-modelo-principal","messages":[{"role":"user","content":"Olá"}],"stream":true}'
```
---
### 7. Troubleshooting Comum
- **“Route not found” ou requests indo para lugar errado** → Verifique se `baseURL` termina com `/v1` e não com `/v1/chat/completions`.
- **Custom provider não aparece** → Certifique-se que o ID do provider no `/connect` bate com a chave no `opencode.json`.
- **Sem streaming / resposta lenta** → Implemente SSE corretamente.
- **Tool calls não funcionam** → Sua API precisa retornar `tool_calls` no formato OpenAI exato.
- **Erro de autenticação** → Use `apiKey` ou `headers` corretamente.
---
### Referências Oficiais
- **Docs Principais**: https://opencode.ai/docs/providers/
- **Models**: https://opencode.ai/docs/models/
- **Config**: https://opencode.ai/docs/config/
- **Schema de Config**: https://opencode.ai/config.json
- **GitHub**: https://github.com/anomalyco/opencode
- **AI SDK OpenAI-Compatible**: https://ai-sdk.dev/providers/openai-compatible-providers
---
**Resumo Final para Você (que está construindo a API):**
Sua API **precisa ser 100% compatível** com o endpoint OpenAI `/v1/chat/completions` (streaming + tool calling recomendado).
O OpenCode vai:
1. Configurar via `opencode.json` (baseURL + apiKey)
2. Enviar requisições padrão OpenAI
3. Receber streaming ou resposta completa
4. Renderizar tudo lindamente no TUI com suporte a ferramentas
Se você implementar exatamente o padrão OpenAI Chat Completions, vai funcionar perfeitamente com o OpenCode.
Quer que eu monte um **exemplo de servidor mínimo** (em Node.js/Fastify ou Python/FastAPI) que já funcione com o OpenCode? Posso criar um template completo para você.
+146
View File
@@ -0,0 +1,146 @@
# Brand Briefing — Gemini Nexus
## 1. Nome e Descrição
| Campo | Valor |
|-------|-------|
| **Nome** | Gemini Nexus |
| **Product Name** | Gemini Nexus |
| **Package Name** | gemini-nexus |
| **Versão** | 0.11.1 |
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
| **Descrição PT-BR** | Gerenciador multi-contas profissional para Google Gemini & Claude AI |
| **Autor** | evandrodevbr / Draculabo |
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
---
## 2. Propósito do Aplicativo
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
### Funcionalidades Principais
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
- **Auto-switching inteligente**: Troca automática de conta quando a cota acaba ou há rate limit
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
- **Proxy API local**: Servidor proxy integrado compatível OpenAI/Anthropic (porta 8045)
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
### Público-Alvo
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
- Power users que precisam alternar entre múltiplas contas de IA
- Usuários que precisam de um proxy API local confiável
---
## 3. Tom de Voz / Personalidade
- **Profissional e técnica**: Termos como "Nexus", "Proxy", "Gateway", "Quota", "Circuit Breaker"
- **Confiável e estável**: Monitoramento, health checks, logging robusto
- **Developer-first**: Suporte a múltiplas IDEs, geração de cURL/Python
- **Moderna e minimalista**: Interface limpa, componentes modulares
- **Global**: Suporte a i18n (EN, PT-BR, ZH, RU, VI)
---
## 4. Estilo Visual Atual
### Tema
- Dual theme (claro/escuro) com detecção automática do sistema
- Extremamente minimalista e quase monocromático
### Paleta de Cores (Tailwind OKLCH)
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|------|-----------|------------|---------|-------------|----------|
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
### Cores Funcionais
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
- **Amber/Yellow** — quota média, alertas
- **Rose/Red** — quota baixa, erro, stopped
- **Blue** — AI credits, informações premium
### Tipografia
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
- **Geist Mono** (monospace) — dados técnicos, números de quota
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
### Características de UI
- Bordas arredondadas (`0.5rem` / 8px)
- Sombras sutis
- Cards com hover states suaves
- Interface limpa com espaçamento generoso
---
## 5. Assets Visuais Existentes
| Arquivo | Descrição |
|---------|-----------|
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
| `src/assets/tray.png` | Ícone da system tray |
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
---
## 6. Conceitos Visuais Sugeridos para a Logo
### A. Nodo / Conexão (Nexus)
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
- Círculo central com 2-3 linhas finas irradiando
- Dois círculos interligados (Gemini + Claude)
### B. Infinito / Loop (Gemini + Proxy)
- Símbolo de infinito (∞) modificado como circuito/loop de dados
- Referência à dualidade "Gemini" (gêmeos)
- Curva contínua elegante com seta indicando fluxo
### C. Cubo / Hexágono (Nexus / Hub)
- Hexágono ou cubo isométrico minimalista
- Linhas finas, perspectiva suave
- Face "aberta" ou conectada
### D. Monograma "GN"
- Letras "G" e "N" abstraídas como nodo de rede
- Estilo geométrico, similar a Tomorrow ou Geist
### E. Proxy / Pipe (Fluxo de Dados)
- Duas barras paralelas com seta passando entre elas
- "Pipe" minimalista — conduz tráfego entre APIs
---
## 7. Diretrizes para a Nova Logo
| Elemento | Especificação |
|----------|---------------|
| **Estilo** | Minimalista, geométrica, precisa, técnica |
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
| **Versões necessárias** | Dark mode e light mode |
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
| **Monocromática** | Deve funcionar em preto e branco puro |
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
---
## 8. Conclusão
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
A logo deve transmitir:
- **Tecnologia** e **precisão**
- **Conectividade** e **hub central**
- **Profissionalismo** e **confiança**
- **Modernidade** e **minimalismo**
+1 -1
View File
@@ -31,7 +31,7 @@ export default defineConfig([
'unused-imports': unusedImports, 'unused-imports': unusedImports,
}, },
rules: { rules: {
'@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-explicit-any': 'warn',
'@typescript-eslint/no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'off',
'@typescript-eslint/no-empty-object-type': 'off', '@typescript-eslint/no-empty-object-type': 'off',
'@typescript-eslint/ban-ts-comment': 'off', '@typescript-eslint/ban-ts-comment': 'off',
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+18 -25
View File
@@ -1,3 +1,4 @@
import { execSync } from 'child_process';
import crypto from 'crypto'; import crypto from 'crypto';
import { FuseV1Options, FuseVersion } from '@electron/fuses'; import { FuseV1Options, FuseVersion } from '@electron/fuses';
import type { import type {
@@ -17,16 +18,26 @@ import { FusesPlugin } from '@electron-forge/plugin-fuses';
import { VitePlugin } from '@electron-forge/plugin-vite'; import { VitePlugin } from '@electron-forge/plugin-vite';
import type { ForgeConfig } from '@electron-forge/shared-types'; import type { ForgeConfig } from '@electron-forge/shared-types';
import MakerAppImage from '@pengx17/electron-forge-maker-appimage'; import MakerAppImage from '@pengx17/electron-forge-maker-appimage';
// @ts-ignore
import setLanguages from 'electron-packager-languages'; import setLanguages from 'electron-packager-languages';
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { stringify as yamlStringify } from 'yaml'; import { stringify as yamlStringify } from 'yaml';
const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path']; const nativeModules = ['better-sqlite3', 'keytar', 'bindings', 'file-uri-to-path'];
const ResolvedMakerAppImage = MakerAppImage; const ResolvedMakerAppImage: any = (MakerAppImage as any).default || MakerAppImage;
const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']); const keepLanguages = new Set(['en', 'en-US', 'zh-CN', 'ru']);
const windowsExecutableName = 'gemini-nexus'; const windowsExecutableName = 'gemini-nexus';
function isWixAvailable(): boolean {
try {
execSync('candle -?', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const isStartCommand = process.argv.some((arg) => arg.includes('start')); const isStartCommand = process.argv.some((arg) => arg.includes('start'));
const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm|msi)$/; const artifactRegex = /.*\.(?:exe|dmg|AppImage|zip|deb|rpm|msi)$/;
@@ -176,25 +187,8 @@ function getChecksumArchLabel(platform: string, arch: string) {
} }
const appImageMaker = new ResolvedMakerAppImage({ const appImageMaker = new ResolvedMakerAppImage({
config: { options: {
icons: [ icon: 'images/128x128.png',
{
file: 'images/32x32.png',
size: 32,
},
{
file: 'images/64x64.png',
size: 64,
},
{
file: 'images/128x128.png',
size: 128,
},
{
file: 'images/[email protected]',
size: 256,
},
],
}, },
}); });
appImageMaker.name = '@pengx17/electron-forge-maker-appimage'; appImageMaker.name = '@pengx17/electron-forge-maker-appimage';
@@ -449,10 +443,9 @@ const config: ForgeConfig = {
makers: [ makers: [
new MakerSquirrel({ new MakerSquirrel({
setupIcon: 'images/icon.ico', setupIcon: 'images/icon.ico',
iconUrl: iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
'https://raw.githubusercontent.com/Draculabo/AntigravityManager/main/images/icon.ico',
}), }),
...(process.platform === 'win32' && process.arch === 'x64' ...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
? [ ? [
new MakerWix({ new MakerWix({
language: 1033, language: 1033,
@@ -484,8 +477,8 @@ const config: ForgeConfig = {
name: '@electron-forge/publisher-github', name: '@electron-forge/publisher-github',
config: { config: {
repository: { repository: {
owner: 'Draculabo', owner: 'evandrodevbr',
name: 'AntigravityManager', name: 'GeminiNexus',
}, },
draft: true, draft: true,
prerelease: false, prerelease: false,
+1
View File
@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<title>Gemini Nexus</title> <title>Gemini Nexus</title>
<link rel="icon" type="image/png" href="/src/assets/icon.png" />
<meta http-equiv="Content-Security-Policy" content="script-src 'self';" /> <meta http-equiv="Content-Security-Policy" content="script-src 'self';" />
<link rel="stylesheet" href="/src/styles/global.css" /> <link rel="stylesheet" href="/src/styles/global.css" />
</head> </head>
+5746 -11282
View File
File diff suppressed because it is too large. Load diff
+36 -15
View File
@@ -1,7 +1,7 @@
{ {
"name": "gemini-nexus", "name": "gemini-nexus",
"productName": "Gemini Nexus", "productName": "Gemini Nexus",
"version": "0.11.1", "version": "0.13.0",
"description": "Gemini Nexus - Electron App", "description": "Gemini Nexus - Electron App",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
@@ -10,7 +10,10 @@
"dev": "cross-env DEBUG=electron-forge:* electron-forge start", "dev": "cross-env DEBUG=electron-forge:* electron-forge start",
"package": "electron-forge package", "package": "electron-forge package",
"make": "electron-forge make", "make": "electron-forge make",
"make:win": "node scripts/build-windows.js",
"publish": "electron-forge publish", "publish": "electron-forge publish",
"setup": "node scripts/setup-dev.js",
"postinstall": "node scripts/setup-dev.js --check-only",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --check .", "format": "prettier --check .",
"format:write": "prettier --write .", "format:write": "prettier --write .",
@@ -21,15 +24,15 @@
"test:all": "vitest run && playwright test", "test:all": "vitest run && playwright test",
"type-check": "tsc --noEmit" "type-check": "tsc --noEmit"
}, },
"author": "Draculabo", "author": "evandrodevbr",
"license": "CC-BY-NC-SA-4.0", "license": "CC-BY-NC-SA-4.0",
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.1", "@electron-forge/cli": "^7.6.1",
"@electron-forge/maker-deb": "^7.11.1", "@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.11.1", "@electron-forge/maker-dmg": "^7.11.1",
"@electron-forge/maker-rpm": "^7.11.1", "@electron-forge/maker-rpm": "^7.11.1",
"@electron-forge/maker-squirrel": "^7.11.1", "@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-wix": "^7.11.1", "@electron-forge/maker-wix": "^7.9.0",
"@electron-forge/maker-zip": "^7.11.1", "@electron-forge/maker-zip": "^7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1", "@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
"@electron-forge/plugin-fuses": "^7.11.1", "@electron-forge/plugin-fuses": "^7.11.1",
@@ -40,14 +43,14 @@
"@electron/rebuild": "^3.7.1", "@electron/rebuild": "^3.7.1",
"@eslint/compat": "^1.4.1", "@eslint/compat": "^1.4.1",
"@eslint/js": "^9.39.0", "@eslint/js": "^9.39.0",
"@pengx17/electron-forge-maker-appimage": "^1.2.1", "@pengx17/electron-forge-maker-appimage": "^1.0.2",
"@playwright/test": "^1.56.1", "@playwright/test": "^1.56.1",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
"@semantic-release/git": "^10.0.1", "@semantic-release/git": "^10.0.1",
"@semantic-release/github": "^12.0.6", "@semantic-release/github": "^12.0.6",
"@sentry/vite-plugin": "^4.8.0", "@sentry/vite-plugin": "^4.8.0",
"@swc/core": "^1.15.8", "@swc/core": "^1.15.8",
"@tailwindcss/vite": "^4.1.17", "@tailwindcss/vite": "^4.2.4",
"@tanstack/router-plugin": "^1.135.0", "@tanstack/router-plugin": "^1.135.0",
"@testing-library/jest-dom": "^6.9.1", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.0", "@testing-library/react": "^16.3.0",
@@ -64,7 +67,7 @@
"code-inspector-plugin": "^1.3.3", "code-inspector-plugin": "^1.3.3",
"conventional-changelog-conventionalcommits": "^9.1.0", "conventional-changelog-conventionalcommits": "^9.1.0",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"electron": "^37.3.1", "electron": "^41.3.0",
"electron-devtools-installer": "^4.0.0", "electron-devtools-installer": "^4.0.0",
"electron-packager-languages": "^0.6.0", "electron-packager-languages": "^0.6.0",
"electron-playwright-helpers": "^1.8.2", "electron-playwright-helpers": "^1.8.2",
@@ -78,6 +81,7 @@
"happy-dom": "^20.8.9", "happy-dom": "^20.8.9",
"icon-gen": "^5.0.0", "icon-gen": "^5.0.0",
"jsdom": "^27.1.0", "jsdom": "^27.1.0",
"node-abi": "^4.28.0",
"node-addon-api": "^8.3.1", "node-addon-api": "^8.3.1",
"prettier": "^3.6.2", "prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.7.1", "prettier-plugin-tailwindcss": "^0.7.1",
@@ -89,18 +93,22 @@
"typescript": "^5.9.3", "typescript": "^5.9.3",
"typescript-eslint": "^8.46.3", "typescript-eslint": "^8.46.3",
"unplugin-swc": "^1.5.9", "unplugin-swc": "^1.5.9",
"vite": "^5.4.21", "vite": "^8.0.10",
"vite-tsconfig-paths": "^6.1.1",
"vitest": "^4.0.8" "vitest": "^4.0.8"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "^1.14.3",
"@grpc/proto-loader": "^0.8.0",
"@icons-pack/react-simple-icons": "^13.8.0", "@icons-pack/react-simple-icons": "^13.8.0",
"@nestjs/common": "^11.1.18", "@nestjs/common": "^11.1.18",
"@nestjs/core": "^11.1.18", "@nestjs/core": "^11.1.18",
"@nestjs/microservices": "^11.1.18", "@nestjs/microservices": "^11.1.18",
"@nestjs/platform-express": "^11.1.18", "@nestjs/platform-express": "^11.1.18",
"@nestjs/platform-fastify": "^11.1.18", "@nestjs/platform-fastify": "^11.0.20",
"@nestjs/websockets": "^11.1.19",
"@nivo/bar": "^0.99.0",
"@nivo/core": "^0.99.0",
"@nivo/line": "^0.99.0",
"@nivo/pie": "^0.99.0",
"@orpc/client": "^1.13.14", "@orpc/client": "^1.13.14",
"@orpc/server": "^1.13.14", "@orpc/server": "^1.13.14",
"@radix-ui/react-avatar": "^1.1.11", "@radix-ui/react-avatar": "^1.1.11",
@@ -123,7 +131,6 @@
"@tanstack/react-query": "^5.90.7", "@tanstack/react-query": "^5.90.7",
"@tanstack/react-router": "^1.139.3", "@tanstack/react-router": "^1.139.3",
"@tanstack/react-router-devtools": "^1.135.0", "@tanstack/react-router-devtools": "^1.135.0",
"@types/uuid": "^11.0.0",
"axios": "^1.15.0", "axios": "^1.15.0",
"better-sqlite3": "^12.4.6", "better-sqlite3": "^12.4.6",
"class-transformer": "^0.5.1", "class-transformer": "^0.5.1",
@@ -133,8 +140,9 @@
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
"fastify": "^5.8.4", "fastify": "^5.8.5",
"find-process": "^2.0.0", "find-process": "^2.0.0",
"gpt-tokenizer": "^3.4.0",
"i18next": "^25.6.3", "i18next": "^25.6.3",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
"keytar": "^7.9.0", "keytar": "^7.9.0",
@@ -150,7 +158,7 @@
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"undici": "^7.16.0", "undici": "^7.16.0",
"update-electron-app": "^3.1.1", "update-electron-app": "^3.1.1",
"uuid": "^13.0.0", "uuid": "^14.0.0",
"winston": "^3.19.0", "winston": "^3.19.0",
"winston-daily-rotate-file": "^5.0.0", "winston-daily-rotate-file": "^5.0.0",
"yaml": "^2.8.2", "yaml": "^2.8.2",
@@ -162,5 +170,18 @@
"@sentry/cli", "@sentry/cli",
"@swc/core", "@swc/core",
"protobufjs" "protobufjs"
] ],
"pnpm": {
"overrides": {
"node-abi": "^4.28.0",
"fastify": "^5.8.5",
"tar": "^7.5.13",
"@octokit/request-error": "^5.1.1",
"@octokit/plugin-paginate-rest": "^9.2.2",
"@octokit/request": "^8.4.1",
"tmp": "^0.2.4",
"@tootallnate/once": "^3.0.1",
"esbuild": "^0.25.0"
}
}
} }
+8
View File
@@ -0,0 +1,8 @@
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
@@ -0,0 +1,11 @@
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);
+18234
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
allowBuilds:
'@bitdisaster/exe-icon-extractor': true
'@nestjs/core': true
'@sentry/cli': true
'@swc/core': true
better-sqlite3: true
electron: true
electron-winstaller: true
esbuild: true
keytar: true
protobufjs: true
sharp: true
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
+51
View File
@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const { execSync, spawn } = require('child_process');
const fs = require('fs');
const path = require('path');
const WIX_ZIP = path.join(process.cwd(), 'wix314-binaries.zip');
const WIX_DIR = path.join(process.cwd(), 'tools', 'wix314');
function extractWix() {
if (!fs.existsSync(WIX_ZIP)) {
console.error(`WiX zip not found: ${WIX_ZIP}`);
process.exit(1);
}
if (fs.existsSync(WIX_DIR)) {
console.log(`WiX already extracted at ${WIX_DIR}`);
return;
}
console.log(`Extracting WiX binaries to ${WIX_DIR}...`);
fs.mkdirSync(WIX_DIR, { recursive: true });
// Use PowerShell Expand-Archive (built into Windows)
const psCommand = `Expand-Archive -Path "${WIX_ZIP}" -DestinationPath "${WIX_DIR}" -Force`;
execSync(psCommand, { shell: 'powershell.exe', stdio: 'inherit' });
console.log('WiX extraction complete.');
}
function setupWixPath() {
// electron-wix-msi's detect-wix.js runs execSync('candle -?') which relies on PATH
process.env.PATH = `${WIX_DIR};${process.env.PATH}`;
console.log(`Added ${WIX_DIR} to PATH`);
}
function runMake() {
console.log('Running electron-forge make...');
const make = spawn('npx', ['electron-forge', 'make'], {
stdio: 'inherit',
shell: true,
env: process.env,
});
make.on('exit', (code) => {
process.exit(code ?? 0);
});
}
extractWix();
setupWixPath();
runMake();
+619
View File
@@ -0,0 +1,619 @@
/**
* Cross-platform development environment setup for Gemini Nexus.
*
* Detects and installs native build toolchains required by better-sqlite3,
* keytar, and other native Node.js modules used in this Electron project.
*
* Platforms:
* - Windows → Visual Studio 2022 Build Tools (C++ workload)
* - macOS → Xcode Command Line Tools
* - Ubuntu → build-essential, python3, libsecret-1-dev
* - Manjaro/Arch → base-devel, python, libsecret
*
* Usage:
* node scripts/setup-dev.js # auto-detect and install/guide
* node scripts/setup-dev.js --check-only # only verify, don't install
*/
'use strict';
const { execSync, spawnSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
// ─── Terminal Color Helpers ─────────────────────────────────────────────────
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
const DIM = '\x1b[2m';
const RED = '\x1b[31m';
const GREEN = '\x1b[32m';
const YELLOW = '\x1b[33m';
const CYAN = '\x1b[36m';
const MAGENTA = '\x1b[35m';
// Disable colors if NO_COLOR is set or output is piped
const useColor = process.env.NO_COLOR === undefined && process.stdout.isTTY;
function color(code, text) {
return useColor ? `${code}${text}${RESET}` : text;
}
function header(text) {
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
}
function success(text) {
console.log(` ${color(GREEN, '✓')} ${text}`);
}
function warn(text) {
console.log(` ${color(YELLOW, '⚠')} ${text}`);
}
function error(text) {
console.log(` ${color(RED, '✗')} ${text}`);
}
function info(text) {
console.log(` ${color(CYAN, '→')} ${text}`);
}
function dim(text) {
console.log(` ${color(DIM, text)}`);
}
function section(title) {
console.log(`\n ${color(BOLD, title)}:`);
}
// ─── Platform Detection ─────────────────────────────────────────────────────
const platform = os.platform(); // 'win32' | 'darwin' | 'linux'
const arch = os.arch();
/** Detect Linux distribution from os-release */
function detectLinuxDistro() {
try {
const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
const idMatch = osRelease.match(/^ID="?([^"\n]+)"?/m);
const idLikeMatch = osRelease.match(/^ID_LIKE="?([^"\n]+)"?/m);
const id = idMatch ? idMatch[1].toLowerCase() : '';
const idLike = idLikeMatch ? idLikeMatch[1].toLowerCase() : '';
if (id === 'ubuntu' || id === 'debian' || idLike.includes('debian')) {
return 'debian';
}
if (id === 'arch' || id === 'manjaro' || idLike.includes('arch')) {
return 'arch';
}
if (id === 'fedora' || idLike.includes('fedora') || idLike.includes('rhel')) {
return 'fedora';
}
return id || 'unknown';
} catch {
return 'unknown';
}
}
// ─── Tool Checkers ──────────────────────────────────────────────────────────
/** Check if a command exists on PATH */
function commandExists(cmd) {
try {
const whereCmd = platform === 'win32' ? 'where' : 'which';
execSync(`${whereCmd} "${cmd}"`, { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
/** Check if running with administrator/sudo privileges */
function hasAdminPrivileges() {
if (platform === 'win32') {
try {
// On Windows, check via net session (requires admin)
execSync('net session', { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
// On Unix, check if UID is 0
return process.getuid ? process.getuid() === 0 : false;
}
/** Check Windows: Visual Studio Build Tools */
function checkWindows() {
const results = { ok: true, missing: [] };
// Check for cl.exe (C++ compiler from VS Build Tools)
if (commandExists('cl.exe')) {
success('MSVC Compiler (cl.exe) found');
} else {
// Try to find VS installation via vswhere or registry
let vsFound = false;
// vswhere.exe (shipped with VS 2017+)
const vswherePaths = [
'C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe',
'C:\\Program Files\\Microsoft Visual Studio\\Installer\\vswhere.exe',
];
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();
if (output) {
vsFound = true;
success(`Visual Studio found at: ${output}`);
break;
}
} catch {
// vswhere not found, continue
}
}
// Check registry fallback
if (!vsFound) {
try {
execSync(
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
{ stdio: 'ignore' },
);
vsFound = true;
success('Visual Studio 2022 found (registry)');
} catch {
// Not found in registry either
}
}
if (!vsFound) {
error('MSVC Build Tools not found');
results.ok = false;
results.missing.push('msvc');
}
}
// Check Python (needed by node-gyp)
if (commandExists('python') || commandExists('python3')) {
success('Python found');
} else {
error('Python not found (required by node-gyp)');
results.ok = false;
results.missing.push('python');
}
return results;
}
/** Check macOS: Xcode Command Line Tools */
function checkMacOS() {
const results = { ok: true, missing: [] };
try {
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
if (xcodePath) {
success(`Xcode CLT found at: ${xcodePath}`);
}
} catch {
error('Xcode Command Line Tools not found');
results.ok = false;
results.missing.push('xcode-clt');
}
if (commandExists('make') || commandExists('gmake')) {
success('make found');
} else {
error('make not found');
results.ok = false;
results.missing.push('make');
}
return results;
}
/** Check Linux: build-essential, python3, libsecret */
function checkLinux() {
const results = { ok: true, missing: [] };
const distro = detectLinuxDistro();
section(`Linux Distribution Detected: ${distro || 'unknown'}`);
// gcc / g++
if (commandExists('gcc') || commandExists('cc')) {
success('C compiler found');
} else {
error('C compiler not found');
results.ok = false;
results.missing.push('compiler');
}
if (commandExists('g++') || commandExists('c++')) {
success('C++ compiler found');
} else {
error('C++ compiler not found');
results.ok = false;
results.missing.push('compiler');
}
// make
if (commandExists('make')) {
success('make found');
} else {
error('make not found');
results.ok = false;
results.missing.push('make');
}
// Python 3
if (commandExists('python3')) {
success('Python 3 found');
} else if (commandExists('python')) {
// Check if python is python3
try {
const ver = execSync('python --version', { encoding: 'utf8' });
if (ver.includes('3.')) {
success('Python 3 found (as python)');
} else {
error('Python 3 not found (python is v2)');
results.ok = false;
results.missing.push('python3');
}
} catch {
error('Python not found');
results.ok = false;
results.missing.push('python3');
}
} else {
error('Python 3 not found');
results.ok = false;
results.missing.push('python3');
}
// libsecret (needed by keytar)
try {
if (distro === 'debian') {
execSync('dpkg -s libsecret-1-dev', { stdio: 'ignore' });
success('libsecret-1-dev found');
} else if (distro === 'arch') {
execSync('pacman -Qi libsecret', { stdio: 'ignore' });
success('libsecret found');
} else {
// Check for pkg-config
try {
execSync('pkg-config --exists libsecret-1', { stdio: 'ignore' });
success('libsecret (pkg-config) found');
} catch {
warn('libsecret not detected (may not be needed if using prebuilt keytar)');
}
}
} catch {
warn('libsecret not detected (may not be needed if using prebuilt keytar)');
}
return results;
}
// ─── Installation Helpers ───────────────────────────────────────────────────
/** Run a command and stream output, return exit code */
function runCommand(cmd, args, opts = {}) {
return new Promise((resolve) => {
console.log(`\n ${color(CYAN, 'Running:')} ${color(DIM, `${cmd} ${args.join(' ')}`)}`);
const child = spawnSync(cmd, args, {
stdio: 'inherit',
shell: platform === 'win32',
...opts,
});
resolve(child.status);
});
}
/** Install Windows: Visual Studio 2022 Build Tools via winget */
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('');
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"'));
return false;
}
console.log('');
info('Attempting automatic installation of Visual Studio 2022 Build Tools...');
dim('This may take several minutes and requires administrator privileges.');
console.log('');
if (!hasAdminPrivileges()) {
warn('Not running as Administrator. winget may prompt for elevation.');
}
const status = await runCommand('winget', [
'install',
'Microsoft.VisualStudio.2022.BuildTools',
'--override',
'"--quiet --wait --norestart --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --includeRecommended"',
]);
if (status === 0) {
success('Visual Studio 2022 Build Tools installed successfully.');
console.log('');
warn('You may need to restart your terminal for PATH changes to take effect.');
return true;
}
error('Automatic installation failed. Please install manually.');
return false;
}
/** Install macOS: Xcode Command Line Tools */
async function installMacOS() {
console.log('');
info('Installing Xcode Command Line Tools...');
dim('A GUI dialog will appear. Click "Install" to proceed.');
const status = await runCommand('xcode-select', ['--install']);
// xcode-select --install returns 1 if already installed or cancelled
if (status === 0) {
success('Xcode Command Line Tools installation initiated.');
return true;
}
// Check if already installed (common false-negative)
try {
execSync('xcode-select -p', { stdio: 'ignore' });
success('Xcode Command Line Tools are already installed.');
return true;
} catch {
warn('Xcode CLT installation was cancelled or failed.');
warn('You can install manually via: xcode-select --install');
return false;
}
}
/** Install Linux: distribution-specific packages */
async function installLinux() {
const distro = detectLinuxDistro();
if (distro === 'debian') {
// Ubuntu / Debian
if (!hasAdminPrivileges()) {
warn('Root privileges required. You may be prompted for your password.');
}
const status = await runCommand('sudo', [
'apt-get', 'install', '-y',
'build-essential',
'python3',
'libsecret-1-dev',
'pkg-config',
]);
if (status === 0) {
success('Build dependencies installed successfully.');
return true;
}
error('Failed to install build dependencies.');
return false;
}
if (distro === 'arch') {
// Manjaro / Arch
if (!hasAdminPrivileges()) {
warn('Root privileges required. You may be prompted for your password.');
}
const status = await runCommand('sudo', [
'pacman', '-S', '--needed', '--noconfirm',
'base-devel',
'python',
'libsecret',
'pkg-config',
]);
if (status === 0) {
success('Build dependencies installed successfully.');
return true;
}
error('Failed to install build dependencies.');
return false;
}
if (distro === 'fedora') {
if (!hasAdminPrivileges()) {
warn('Root privileges required. You may be prompted for your password.');
}
const status = await runCommand('sudo', [
'dnf', 'install', '-y',
'gcc-c++',
'make',
'python3',
'libsecret-devel',
'pkg-config',
]);
if (status === 0) {
success('Build dependencies installed successfully.');
return true;
}
error('Failed to install build dependencies.');
return false;
}
// Unknown distro - provide generic instructions
console.log('');
warn(`Unknown Linux distribution: ${distro}`);
console.log('');
console.log(' You need the following packages to compile native Node.js modules:');
console.log(' - C/C++ compiler (gcc, g++)');
console.log(' - GNU Make');
console.log(' - Python 3');
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.');
return false;
}
// ─── Main ───────────────────────────────────────────────────────────────────
async function main() {
const args = process.argv.slice(2);
const checkOnly = args.includes('--check-only') || args.includes('--check');
console.log('');
console.log(color(BOLD + CYAN, '┌──────────────────────────────────────────┐'));
console.log(color(BOLD + CYAN, '│ Gemini Nexus - Dev Environment Setup │'));
console.log(color(BOLD + CYAN, '└──────────────────────────────────────────┘'));
console.log('');
dim(`Platform: ${platform} (${arch})`);
dim(`Node.js: ${process.version}`);
// Step 1: Check current state
header('Checking Build Toolchain');
let results;
switch (platform) {
case 'win32':
results = checkWindows();
break;
case 'darwin':
results = checkMacOS();
break;
case 'linux':
results = checkLinux();
break;
default:
console.log('');
error(`Unsupported platform: ${platform}`);
process.exit(1);
}
// Print summary
console.log('');
if (results.ok) {
success('All build tools are present and ready.');
console.log('');
dim('You can now run: npm install && npm start');
return;
}
// ── Missing tools ──
console.log('');
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
if (checkOnly) {
console.log('');
warn('Build tools are missing. Native modules (better-sqlite3, keytar) require compilation.');
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.');
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
return;
}
// Step 2: Attempt installation
header('Installing Build Dependencies');
let installed = false;
switch (platform) {
case 'win32':
installed = await installWindows();
break;
case 'darwin':
installed = await installMacOS();
break;
case 'linux':
installed = await installLinux();
break;
}
// Step 3: Final status
console.log('');
if (installed) {
success('Setup complete. You can now run: npm install && npm start');
console.log('');
dim('If you encounter issues, restart your terminal and try again.');
} else {
console.log('');
warn('Automatic installation was not successful.');
warn('Please install the required tools manually, then run npm install again.');
printManualInstructions(platform, results.missing);
process.exit(1);
}
}
function printManualInstructions(platform, missing) {
console.log('');
console.log(color(BOLD, ' ── Manual Installation Instructions ──'));
console.log('');
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('');
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('');
console.log(' 4. Restart your terminal after installation.');
break;
case 'darwin':
console.log(' Run this command in your terminal:');
console.log(color(DIM, ' xcode-select --install'));
console.log('');
console.log(' Then click "Install" in the dialog that appears.');
break;
case 'linux': {
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'));
} else if (distro === 'arch') {
console.log(' Run:');
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'));
} else {
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
}
break;
}
}
console.log('');
}
// ─── Run ────────────────────────────────────────────────────────────────────
main().catch((err) => {
console.error(color(RED, `\nUnexpected error: ${err.message}`));
process.exit(1);
});
+3 -5
View File
@@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import { useEffect } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import { syncWithLocalTheme } from './actions/theme'; import { syncWithLocalTheme } from './actions/theme';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -29,12 +29,10 @@ const queryClient = new QueryClient();
const root = createRoot(document.getElementById('app')!); const root = createRoot(document.getElementById('app')!);
root.render( root.render(
<React.StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system"> <ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="dark">
<App /> <App />
<Toaster /> <Toaster />
</ThemeProvider> </ThemeProvider>
</QueryClientProvider> </QueryClientProvider>,
</React.StrictMode>,
); );
Binary file not shown.

After

Width:  |  Height:  |  Size: 875 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 390 KiB

After

Width:  |  Height:  |  Size: 736 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 659 B

After

Width:  |  Height:  |  Size: 44 KiB

+2 -2
View File
@@ -114,8 +114,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
<Card <Card
className={cn( className={cn(
'relative overflow-hidden transition-all duration-200', 'relative overflow-hidden transition-all duration-200',
'hover:shadow-primary/10 hover:shadow-xl',
isCurrent ? 'border-primary/50 bg-primary/5' : 'hover:border-primary/20 cursor-pointer', isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
(isSwitching || isDeleting) && 'pointer-events-none opacity-60', (isSwitching || isDeleting) && 'pointer-events-none opacity-60',
)} )}
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)} onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
+2 -2
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'; import { useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useCloudAccounts, useSetAccountProxy } from '@/hooks/useCloudAccounts'; import { useCloudAccounts, useSetAccountProxy } from '@/hooks/useCloudAccounts';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -67,7 +67,7 @@ function AccountProxyItem({ account }: { account: CloudAccount }) {
/> />
<div className="w-16"> <div className="w-16">
{proxySaved && ( {proxySaved && (
<span className="text-[10px] whitespace-nowrap text-green-500"> <span className="text-[10px] whitespace-nowrap text-emerald-400">
{t('cloud.card.proxySaved')} {t('cloud.card.proxySaved')}
</span> </span>
)} )}
+16 -17
View File
@@ -17,7 +17,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'; } from '@/components/ui/dropdown-menu';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react'; import { MoreVertical, Trash, RefreshCw, Box, Power, Fingerprint, Eye, EyeOff } from 'lucide-react';
@@ -59,9 +58,9 @@ const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [
]; ];
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = { const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-green-500', high: 'text-emerald-400',
medium: 'text-yellow-500', medium: 'text-amber-400',
low: 'text-red-500', low: 'text-rose-400',
}; };
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = { const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
@@ -241,12 +240,12 @@ export function CloudAccountCard({
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase"> <span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
{title} {title}
</span> </span>
<div className="bg-border/50 h-[1px] flex-1" /> <div className="h-[1px] flex-1 bg-white/[0.04]" />
</div> </div>
{models.map(([modelName, info]) => ( {models.map(([modelName, info]) => (
<div <div
key={modelName} key={modelName}
className="group/item hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-sm transition-all" 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 <span
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold" className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
@@ -292,7 +291,7 @@ export function CloudAccountCard({
const providerGroupedQuotaSection = const providerGroupedQuotaSection =
providerStats && providerStats.visibleModels > 0 ? ( providerStats && providerStats.visibleModels > 0 ? (
<> <>
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs"> <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> <span className="font-medium">{t('settings.providerGroupings.overall')}</span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span <span
@@ -344,7 +343,7 @@ export function CloudAccountCard({
return ( return (
<Card <Card
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`} 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' : ''}`}
> >
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2"> <CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
{onToggleSelection && ( {onToggleSelection && (
@@ -366,7 +365,7 @@ export function CloudAccountCard({
className="bg-muted h-10 w-10 rounded-full border" className="bg-muted h-10 w-10 rounded-full border"
/> />
) : ( ) : (
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full border"> <div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground">
{account.name?.[0]?.toUpperCase() || 'A'} {account.name?.[0]?.toUpperCase() || 'A'}
</div> </div>
)} )}
@@ -494,7 +493,7 @@ export function CloudAccountCard({
{account.provider.toUpperCase()} {account.provider.toUpperCase()}
</Badge> </Badge>
{account.is_active && ( {account.is_active && (
<Badge variant="default" className="bg-green-500 text-xs hover:bg-green-600"> <Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20">
{t('cloud.card.active')} {t('cloud.card.active')}
</Badge> </Badge>
)} )}
@@ -515,7 +514,7 @@ export function CloudAccountCard({
)} )}
{account.is_active ? ( {account.is_active ? (
<Button variant="ghost" size="sm" disabled className="text-green-600 opacity-100"> <Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100">
<Power className="mr-1 h-3 w-3" /> <Power className="mr-1 h-3 w-3" />
{t('cloud.card.active')} {t('cloud.card.active')}
</Button> </Button>
@@ -552,7 +551,7 @@ export function CloudAccountCard({
</div> </div>
</CardContent> </CardContent>
<CardFooter className="bg-muted/20 text-muted-foreground justify-center border-t p-2 px-4 text-xs"> <CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground">
<span> <span>
{t('cloud.card.used')}{' '} {t('cloud.card.used')}{' '}
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })} {formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
@@ -614,7 +613,7 @@ export function CompactCloudAccountCard({
); );
return ( return (
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200"> <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]">
{account.avatar_url ? ( {account.avatar_url ? (
<img <img
src={account.avatar_url} src={account.avatar_url}
@@ -622,14 +621,14 @@ export function CompactCloudAccountCard({
className="bg-muted h-7 w-7 rounded-full border" className="bg-muted h-7 w-7 rounded-full border"
/> />
) : ( ) : (
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold"> <div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground">
{account.name?.[0]?.toUpperCase() || 'A'} {account.name?.[0]?.toUpperCase() || 'A'}
</div> </div>
)} )}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold"> <span className="truncate text-[13px] font-semibold">
{account.name || t('cloud.card.unknown')} {account.name || t('cloud.card.unknown')}
</span> </span>
<Badge <Badge
@@ -645,7 +644,7 @@ export function CompactCloudAccountCard({
{account.is_active && ( {account.is_active && (
<Badge <Badge
variant="default" variant="default"
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600" className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20"
> >
{t('cloud.card.active')} {t('cloud.card.active')}
</Badge> </Badge>
@@ -757,7 +756,7 @@ export function CompactCloudAccountCard({
variant="ghost" variant="ghost"
size="sm" size="sm"
disabled disabled
className="h-7 text-xs text-green-600 opacity-100" className="h-7 text-xs text-emerald-400 opacity-100"
> >
<Power className="mr-1 h-3 w-3" /> <Power className="mr-1 h-3 w-3" />
{t('cloud.card.active')} {t('cloud.card.active')}
+23 -29
View File
@@ -85,10 +85,10 @@ import { shouldAutoSubmitGoogleAuthCode } from '@/utils/googleAuthSubmission';
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact'; export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = { const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3', auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
'2-col': 'grid gap-4 grid-cols-2', '2-col': 'grid gap-3 grid-cols-2',
'3-col': 'grid gap-4 grid-cols-3', '3-col': 'grid gap-3 grid-cols-3',
list: 'grid gap-4 grid-cols-1', list: 'grid gap-3 grid-cols-1',
compact: 'flex flex-col gap-2', compact: 'flex flex-col gap-2',
}; };
@@ -99,9 +99,9 @@ const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
}; };
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = { const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-emerald-600 dark:text-emerald-400', high: 'text-emerald-400',
medium: 'text-amber-600 dark:text-amber-400', medium: 'text-amber-400',
low: 'text-rose-600 dark:text-rose-400', low: 'text-rose-400',
}; };
export function CloudAccountList() { export function CloudAccountList() {
@@ -259,16 +259,12 @@ export function CloudAccountList() {
return; return;
} }
const activeClientKey = oauthClients.find((client) => client.is_active)?.key; const activeClientKey = oauthClients.find((client) => client.is_active)?.key;
if (activeClientKey) { if (activeClientKey && activeClientKey !== selectedOAuthClientKey) { setTimeout(() => setSelectedOAuthClientKey(activeClientKey), 0); }
setSelectedOAuthClientKey(activeClientKey);
}
}, [oauthClients, selectedOAuthClientKey]); }, [oauthClients, selectedOAuthClientKey]);
// Listen for Google Auth Code // Listen for Google Auth Code
useEffect(() => { useEffect(() => {
if (window.electron?.onGoogleAuthCode) { if (window.electron?.onGoogleAuthCode) {
console.log('[OAuth] Registering Google auth code IPC listener');
const cleanup = window.electron.onGoogleAuthCode((code) => { const cleanup = window.electron.onGoogleAuthCode((code) => {
console.log('[OAuth] Received Google auth code via IPC:', code?.substring(0, 10) + '...');
lastSubmittedAuthCodeRef.current = null; lastSubmittedAuthCodeRef.current = null;
setAuthCode(code); setAuthCode(code);
}); });
@@ -286,7 +282,6 @@ export function CloudAccountList() {
lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current, lastSubmittedAuthCode: lastSubmittedAuthCodeRef.current,
}) })
) { ) {
console.log('[OAuth] Auto-submitting Google auth code');
submitAuthCode(authCode); submitAuthCode(authCode);
} }
}, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]); }, [addMutation.isPending, authCode, isAddDialogOpen, submitAuthCode]);
@@ -308,7 +303,6 @@ export function CloudAccountList() {
}, [error, errorUpdatedAt, isError, t, toast]); }, [error, errorUpdatedAt, isError, t, toast]);
const handleRefresh = (id: string) => { const handleRefresh = (id: string) => {
console.log(`[Renderer] Triggering refresh for: ${id}`);
refreshMutation.mutate( refreshMutation.mutate(
{ accountId: id }, { accountId: id },
{ {
@@ -669,34 +663,34 @@ export function CloudAccountList() {
return ( return (
<div className="space-y-5 pb-20"> <div className="space-y-5 pb-20">
<div className="bg-card rounded-lg border p-5"> <div className="rounded-xl border border-white/[0.06] p-5">
<div className="flex flex-wrap items-start justify-between gap-4"> <div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex shrink-0 flex-col gap-1"> <div className="flex shrink-0 flex-col gap-1">
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2> <h2 className="text-lg font-semibold tracking-tight">{t('cloud.title')}</h2>
<p className="text-muted-foreground max-w-2xl">{t('cloud.description')}</p> <p className="text-muted-foreground max-w-2xl text-[13px]">{t('cloud.description')}</p>
</div> </div>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<div className="bg-muted/50 rounded-md border px-3 py-2"> <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase"> <div className="text-muted-foreground text-[11px] uppercase">
{t('cloud.card.actions')} {t('cloud.card.actions')}
</div> </div>
<div className="text-base font-semibold">{totalAccounts}</div> <div className="text-sm font-semibold tabular-nums">{totalAccounts}</div>
</div> </div>
<div className="bg-muted/50 rounded-md border px-3 py-2"> <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase"> <div className="text-muted-foreground text-[11px] uppercase">
{t('cloud.card.active')} {t('cloud.card.active')}
</div> </div>
<div className="text-base font-semibold text-emerald-600">{activeAccounts}</div> <div className="text-sm font-semibold text-emerald-400">{activeAccounts}</div>
</div> </div>
<div className="bg-muted/50 rounded-md border px-3 py-2"> <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase"> <div className="text-muted-foreground text-[11px] uppercase">
{t('cloud.card.rateLimited')} {t('cloud.card.rateLimited')}
</div> </div>
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div> <div className="text-sm font-semibold text-rose-400">{rateLimitedAccounts}</div>
</div> </div>
{/* Global Quota */} {/* Global Quota */}
{overallQuotaPercentage !== null && ( {overallQuotaPercentage !== null && (
<div className="bg-muted/50 rounded-md border px-3 py-2"> <div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
<div className="text-muted-foreground text-[11px] uppercase"> <div className="text-muted-foreground text-[11px] uppercase">
{t('cloud.globalQuota')} {t('cloud.globalQuota')}
</div> </div>
@@ -719,13 +713,13 @@ export function CloudAccountList() {
</div> </div>
</div> </div>
<div className="bg-card flex flex-wrap items-center gap-2 rounded-lg border p-3"> <div className="flex flex-wrap items-center gap-2 rounded-xl border border-white/[0.06] bg-card p-2.5">
<div className="bg-muted/50 flex items-center gap-2 rounded-md border px-3 py-2"> <div className="flex items-center gap-2 rounded-lg border border-white/[0.06] px-3 py-1.5">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Zap <Zap
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`} 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"> <Label htmlFor="auto-switch" className="cursor-pointer text-[13px] font-medium">
{t('cloud.autoSwitch')} {t('cloud.autoSwitch')}
</Label> </Label>
</div> </div>
@@ -1114,7 +1108,7 @@ export function CloudAccountList() {
)} )}
{sortedAccounts.length === 0 && ( {sortedAccounts.length === 0 && (
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center"> <div className="text-muted-foreground col-span-full rounded-xl border border-dashed border-white/[0.1] py-14 text-center">
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" /> <Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
<div className="text-sm">{t('cloud.list.noAccounts')}</div> <div className="text-sm">{t('cloud.list.noAccounts')}</div>
</div> </div>
@@ -1123,7 +1117,7 @@ export function CloudAccountList() {
{/* Batch Action Bar */} {/* Batch Action Bar */}
{selectedIds.size > 0 && ( {selectedIds.size > 0 && (
<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 px-6 py-2 shadow-lg"> <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"> <div className="flex items-center gap-2 border-r pr-4">
<span className="text-sm font-semibold"> <span className="text-sm font-semibold">
{t('cloud.batch.selected', { count: selectedIds.size })} {t('cloud.batch.selected', { count: selectedIds.size })}
+2 -3
View File
@@ -134,8 +134,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
if (!open) { if (!open) {
return; return;
} }
void refreshProfiles(); setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
}, [open, refreshProfiles]);
const runAction = async (key: string, action: () => Promise<void>) => { const runAction = async (key: string, action: () => Promise<void>) => {
if (actionLockRef.current) { if (actionLockRef.current) {
@@ -344,7 +343,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{!showLoadingPlaceholder {!showLoadingPlaceholder
? history.map((version) => ( ? history.map((version) => (
<div key={version.id} className="bg-muted/20 rounded-xl border 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="mb-2 flex items-center justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-sm font-medium">{version.label}</div> <div className="truncate text-sm font-medium">{version.label}</div>
+4 -8
View File
@@ -48,12 +48,8 @@ export function ModelVisibilitySettings() {
// Initialize model visibility and provider groupings from config // Initialize model visibility and provider groupings from config
useEffect(() => { useEffect(() => {
if (config?.model_visibility) { if (config?.model_visibility) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
setModelVisibilityMap(config.model_visibility); if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
}
if (config?.provider_groupings_enabled !== undefined) {
setProviderGroupingEnabled(config.provider_groupings_enabled);
}
}, [config?.model_visibility, config?.provider_groupings_enabled]); }, [config?.model_visibility, config?.provider_groupings_enabled]);
// Get all unique models from all accounts // Get all unique models from all accounts
@@ -188,7 +184,7 @@ export function ModelVisibilitySettings() {
return ( return (
<div <div
key={modelId} key={modelId}
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2" className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
> >
<Checkbox <Checkbox
id={`model-${modelId}`} id={`model-${modelId}`}
@@ -210,7 +206,7 @@ export function ModelVisibilitySettings() {
title={t('proxy.copy')} title={t('proxy.copy')}
> >
{copiedModel === modelId ? ( {copiedModel === modelId ? (
<CheckCircle className="h-4 w-4 text-green-500" /> <CheckCircle className="h-4 w-4 text-emerald-400" />
) : ( ) : (
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
)} )}
+7 -7
View File
@@ -37,12 +37,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
} }
return ( return (
<div className="border-border/60 overflow-hidden rounded-lg border"> <div className="overflow-hidden rounded-xl border border-white/[0.06]">
{/* Provider Header */} {/* Provider Header */}
<button <button
type="button" type="button"
onClick={onToggleCollapse} onClick={onToggleCollapse}
className="hover:bg-muted/60 flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors" className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-[13px] transition-colors hover:bg-white/[0.03]"
> >
{isCollapsed ? ( {isCollapsed ? (
<ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" /> <ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
@@ -87,7 +87,7 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
</span> </span>
)} )}
</div> </div>
<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.06]">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`} className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }} style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
@@ -98,12 +98,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
{/* Individual model rows (shown when expanded) */} {/* Individual model rows (shown when expanded) */}
{!isCollapsed && ( {!isCollapsed && (
<div className="border-border/40 border-t"> <div className="border-t border-white/[0.06]">
{visibleModels.map((model, index) => ( {visibleModels.map((model, index) => (
<div <div
key={model.id} key={model.id}
className={`hover:bg-muted/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 pl-9 text-sm transition-colors ${ className={`grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 pl-9 text-[13px] transition-colors hover:bg-white/[0.03] ${
index < visibleModels.length - 1 ? 'border-border/20 border-b' : '' index < visibleModels.length - 1 ? 'border-b border-white/[0.04]' : ''
}`} }`}
> >
<span className="text-muted-foreground min-w-0 truncate" title={model.id}> <span className="text-muted-foreground min-w-0 truncate" title={model.id}>
@@ -126,7 +126,7 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
<span className="text-muted-foreground text-[10px]">{leftLabel}</span> <span className="text-muted-foreground text-[10px]">{leftLabel}</span>
)} )}
</div> </div>
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full"> <div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
<div <div
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`} className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }} style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
+44 -34
View File
@@ -18,7 +18,7 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
const { data: isRunning, isLoading } = useQuery({ const { data: isRunning, isLoading } = useQuery({
queryKey: ['process', 'status'], queryKey: ['process', 'status'],
queryFn: isProcessRunning, queryFn: isProcessRunning,
refetchInterval: 10000, refetchInterval: 30000,
}); });
const startMutation = useMutation({ const startMutation = useMutation({
@@ -52,36 +52,38 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
<TooltipProvider> <TooltipProvider>
<Tooltip delayDuration={0}> <Tooltip delayDuration={0}>
<TooltipTrigger asChild> <TooltipTrigger asChild>
<Button <button
variant="ghost"
size="icon"
onClick={handleToggle} onClick={handleToggle}
disabled={isLoading || isPending} disabled={isLoading || isPending}
className={cn( className={cn(
'h-10 w-10 rounded-lg transition-all duration-300', 'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
isRunning isRunning
? 'bg-green-100/50 text-green-700 hover:bg-green-100 hover:text-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50' ? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
: 'bg-red-100/50 text-red-700 hover:bg-red-100 hover:text-red-800 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50', : 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
)} )}
> >
{isPending ? ( {isPending ? (
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-4 w-4 animate-spin" />
) : ( ) : (
<Power className="h-5 w-5" /> <Power className="h-4 w-4" />
)} )}
</Button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2"> <TooltipContent side="right" className="flex items-center gap-2 text-xs">
<div <div
className={cn('h-2 w-2 rounded-full', isRunning ? 'bg-green-500' : 'bg-red-500')} className={cn(
'h-1.5 w-1.5 rounded-full',
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
)}
/> />
<p> <span>
{isLoading {isLoading
? t('status.checking') ? t('status.checking')
: isRunning : isRunning
? t('status.running') ? t('status.running')
: t('status.stopped')} : t('status.stopped')}
</p> </span>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
@@ -93,23 +95,33 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
return ( return (
<div <div
className={cn( className={cn(
'flex items-center justify-between overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset', 'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
isRunning isRunning
? 'bg-green-100 text-green-900 ring-green-200 dark:bg-green-900/20 dark:text-green-100 dark:ring-green-900/50' ? 'bg-emerald-500/[0.08]'
: 'bg-red-100 text-red-900 ring-red-200 dark:bg-red-900/20 dark:text-red-100 dark:ring-red-900/50', : 'bg-white/[0.02]',
)} )}
> >
<div className="flex flex-1 items-start gap-3"> <div className="flex items-center gap-2.5">
{isLoading ? ( {isLoading ? (
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" /> <Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
) : isRunning ? (
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
) : ( ) : (
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" /> <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',
)} )}
<div className="flex flex-col gap-1 leading-none"> />
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span> )}
<span className="text-sm leading-tight font-medium"> <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 {isLoading
? t('status.checking') ? t('status.checking')
: isRunning : isRunning
@@ -124,22 +136,20 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
onClick={handleToggle} onClick={handleToggle}
disabled={isLoading || isPending} disabled={isLoading || isPending}
className={cn( className={cn(
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all', 'h-7 gap-1.5 rounded-md px-2.5 text-xs',
isRunning isRunning
? 'border-green-200 bg-green-200/50 text-green-900 hover:bg-green-200 dark:border-green-800 dark:bg-green-800/30 dark:text-green-100 dark:hover:bg-green-800/50' ? 'text-emerald-400 hover:bg-emerald-500/10'
: 'border-red-200 bg-red-200/50 text-red-900 hover:bg-red-200 dark:border-red-800 dark:bg-red-800/30 dark:text-red-100 dark:hover:bg-red-800/50', : 'text-muted-foreground hover:bg-white/[0.06]',
)} )}
> >
{isPending ? ( {isPending ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
) : isRunning ? ( ) : isRunning ? (
<Square className="h-3.5 w-3.5 fill-current" /> <Square className="h-3 w-3 fill-current" />
) : ( ) : (
<Play className="h-3.5 w-3.5 fill-current" /> <Play className="h-3 w-3 fill-current" />
)} )}
<span className="ml-2 font-semibold"> <span>{isRunning ? t('action.stop') : t('action.start')}</span>
{isRunning ? t('action.stop') : t('action.start')}
</span>
</Button> </Button>
</div> </div>
); );
+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="flex w-screen items-stretch justify-between">
<div className="draglayer w-full"> <div className="draglayer w-full">
{title && !isMacOS && ( {title && !isMacOS && (
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-gray-400 select-none"> <div className="flex flex-1 p-2 text-xs whitespace-nowrap text-muted-foreground select-none">
{title} {title}
</div> </div>
)} )}
+39
View File
@@ -0,0 +1,39 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MonitorTab } from './advanced/MonitorTab';
import { ToolsTab } from './advanced/ToolsTab';
import { ReplayTab } from './advanced/ReplayTab';
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
import type { ProxyConfig } from '@/types/config';
interface ProxyAdvancedTabProps {
proxyConfig: ProxyConfig;
}
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
const { t } = useTranslation();
return (
<Tabs defaultValue="monitor" className="space-y-5">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
</TabsList>
<TabsContent value="monitor">
<MonitorTab />
</TabsContent>
<TabsContent value="tools">
<ToolsTab />
</TabsContent>
<TabsContent value="replay">
<ReplayTab />
</TabsContent>
<TabsContent value="capabilities">
<CapabilitiesTab />
</TabsContent>
</Tabs>
);
};
@@ -0,0 +1,231 @@
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { groupBy } from 'lodash-es';
import { ipc } from '@/ipc/manager';
import { Badge } from '@/components/ui/badge';
import { Loader2, Eye, Zap, Code, Brain, Star, Layers } from 'lucide-react';
interface ModelCapability {
id: string;
object: string;
displayName?: string;
capabilities: {
vision: boolean;
thinking: boolean;
streaming: boolean;
jsonMode: boolean;
recommended: boolean;
};
limits: {
maxTokens?: number;
maxOutputTokens?: number;
thinkingBudget?: number;
};
}
const CAPABILITY_ICONS = {
vision: Eye,
thinking: Brain,
streaming: Zap,
jsonMode: Code,
recommended: Star,
} as const;
const CAPABILITY_LABELS = {
vision: 'Vision',
thinking: 'Thinking',
streaming: 'Streaming',
jsonMode: 'JSON Mode',
recommended: 'Recommended',
} as const;
function getProvider(modelId: string): string {
const id = modelId.toLowerCase();
if (id.startsWith('gemini')) return 'Google';
if (id.startsWith('claude')) return 'Anthropic';
if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.startsWith('text-'))
return 'OpenAI';
if (id.startsWith('deepseek')) return 'DeepSeek';
if (id.startsWith('meta-llama') || id.startsWith('llama')) return 'Meta';
if (id.startsWith('mistral') || id.startsWith('mixtral')) return 'Mistral';
if (id.startsWith('qwen')) return 'Alibaba';
if (id.startsWith('x-ai') || id.startsWith('grok')) return 'xAI';
if (id.startsWith('sonar')) return 'Perplexity';
if (id.startsWith('command')) return 'Cohere';
return 'Other';
}
/** Format large token counts with K/M suffixes */
function formatTokenCount(value: number): string {
if (value >= 1_000_000) {
return `${(value / 1_000_000).toFixed(1)}M`;
}
if (value >= 1_000) {
return `${(value / 1_000).toFixed(0)}K`;
}
return value.toLocaleString();
}
export const CapabilitiesTab: React.FC = () => {
const { t } = useTranslation();
const { data: capabilitiesData, isLoading } = useQuery({
queryKey: ['proxyAdvanced', 'modelCapabilities'],
queryFn: () => ipc.client.proxyAdvanced.getModelCapabilities(),
refetchInterval: 60000,
});
const models: ModelCapability[] = capabilitiesData?.success
? (capabilitiesData.data as ModelCapability[])
: [];
const groupedModels = useMemo(() => {
return groupBy(models, (m) => getProvider(m.id));
}, [models]);
const sortedProviders = Object.keys(groupedModels).sort((a, b) => {
if (a === 'Other') return 1;
if (b === 'Other') return -1;
return a.localeCompare(b);
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
</h3>
<p className="text-[13px] text-muted-foreground">
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
</p>
</div>
{models.length > 0 && (
<Badge variant="outline" className="text-xs tabular-nums">
<Layers className="mr-1.5 h-3 w-3" />
{models.length} models
</Badge>
)}
</div>
{/* Content */}
{isLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : (
<div className="space-y-8">
{sortedProviders.map((provider) => (
<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">
{provider}
</h4>
<div className="h-px flex-1 bg-white/[0.06]" />
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
{groupedModels[provider].length}
</span>
</div>
{/* Model cards */}
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{groupedModels[provider].map((model) => {
const enabledCount = Object.values(model.capabilities).filter(Boolean).length;
const totalCount = Object.values(model.capabilities).length;
const hasLimits = Object.values(model.limits).some((v) => v != null);
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]"
>
{/* 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"
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">
{enabledCount}/{totalCount}
</span>
</div>
<p
className="font-mono text-[11px] text-muted-foreground truncate"
title={model.id}
>
{model.id}
</p>
</div>
{/* Capability pills */}
<div className="flex flex-wrap gap-1.5 mb-3">
{Object.entries(model.capabilities).map(([key, supported]) => {
const iconKey = key as keyof typeof CAPABILITY_ICONS;
const Icon = CAPABILITY_ICONS[iconKey];
const label = CAPABILITY_LABELS[iconKey];
return (
<div
key={key}
className={`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]'
}`}
title={label}
>
<Icon className="h-3 w-3 shrink-0" />
{label}
</div>
);
})}
</div>
{/* Limits section */}
{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">
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">
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">
THINK {formatTokenCount(model.limits.thinkingBudget)}
</span>
)}
</div>
)}
</div>
);
})}
</div>
</div>
))}
{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" />
<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>
</div>
</div>
)}
</div>
)}
</div>
);
};
@@ -0,0 +1,206 @@
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>
);
};
+173
View File
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { useToast } from '@/components/ui/use-toast';
import { Loader2, Play, RefreshCw, Clock, AlertCircle, CheckCircle } from 'lucide-react';
interface ReplayEntry {
id: string;
timestamp: number;
method: string;
endpoint: string;
status?: number;
duration?: number;
body?: unknown;
}
export const ReplayTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const queryClient = useQueryClient();
const [selectedRequestId, setSelectedRequestId] = useState<string | null>(null);
const {
data: requestsData,
isLoading: requestsLoading,
refetch: refetchRequests,
} = useQuery({
queryKey: ['proxyAdvanced', 'recentRequests'],
queryFn: () => ipc.client.proxyAdvanced.getRecentRequests(),
refetchInterval: 10000,
});
const replayMutation = useMutation({
mutationFn: (requestId: string) => ipc.client.proxyAdvanced.replayRequest({ requestId }),
onSuccess: (data) => {
if (data.success) {
toast({
title: t('proxy.advanced.replay.success'),
description: t('proxy.advanced.replay.replayedSuccessfully'),
});
} else {
toast({
title: t('proxy.advanced.replay.error'),
description: data.error || t('proxy.advanced.replay.unknownError'),
variant: 'destructive',
});
}
setSelectedRequestId(null);
},
onError: (error) => {
toast({
title: t('proxy.advanced.replay.error'),
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
variant: 'destructive',
});
setSelectedRequestId(null);
},
});
const requests: ReplayEntry[] = requestsData?.success ? (requestsData.data as ReplayEntry[]) : [];
const handleReplay = (requestId: string) => {
setSelectedRequestId(requestId);
replayMutation.mutate(requestId);
};
const formatTimestamp = (timestamp: number): string => {
return new Date(timestamp).toLocaleTimeString();
};
const getStatusBadge = (status?: number) => {
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
if (status >= 200 && status < 300) {
return (
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
<CheckCircle className="mr-1 h-3 w-3" />
{status}
</Badge>
);
}
if (status >= 400) {
return (
<Badge variant="destructive">
<AlertCircle className="mr-1 h-3 w-3" />
{status}
</Badge>
);
}
return <Badge variant="secondary">{status}</Badge>;
};
return (
<div className="space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => refetchRequests()}
disabled={requestsLoading}
>
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${requestsLoading ? 'animate-spin' : ''}`} />
{t('proxy.advanced.replay.refresh')}
</Button>
</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">
<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>
</div>
<div className="p-5">
{requestsLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : requests.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-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">
{t('proxy.advanced.replay.noRequests')}
</p>
</div>
) : (
<div className="space-y-2">
{requests.map((request) => (
<div
key={request.id}
className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3 transition-colors hover:bg-white/[0.02]"
>
<div className="flex items-center gap-3">
{getStatusBadge(request.status)}
<div className="flex flex-col">
<span className="text-[13px] font-medium">
{request.method} {request.endpoint}
</span>
<span className="text-xs text-muted-foreground">
{formatTimestamp(request.timestamp)}
{request.duration && ` · ${request.duration}ms`}
</span>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => handleReplay(request.id)}
disabled={replayMutation.isPending && selectedRequestId === request.id}
>
{replayMutation.isPending && selectedRequestId === request.id ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Play className="mr-2 h-4 w-4" />
)}
{t('proxy.advanced.replay.replay')}
</Button>
</div>
))}
</div>
)}
</div>
</div>
</div>
);
};
+134
View File
@@ -0,0 +1,134 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries } from '@tanstack/react-query';
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';
const IDE_CONFIGS = [
{ key: 'vscode', label: 'VS Code', icon: Code2 },
{ key: 'cursor', label: 'Cursor', icon: Terminal },
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
] as const;
export const ToolsTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const { data: parityData, isLoading: parityLoading } = useQuery({
queryKey: ['proxyAdvanced', 'parityCounters'],
queryFn: () => ipc.client.proxyAdvanced.getParityCounters(),
refetchInterval: 30000,
});
const ideQueries = useQueries({
queries: [
{
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
},
{
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
},
],
});
const parity = parityData?.success ? parityData.data : undefined;
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast({ title: t('proxy.advanced.tools.copyConfig') });
};
const getIdeInstructions = (key: string): string => {
if (key === 'claude-code') {
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
}
const index = key === 'vscode' ? 0 : 1;
const result = ideQueries[index].data;
if (result?.success && result.data) {
const d = result.data;
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
}
return '';
};
const isIdeLoading = ideQueries.some((q) => q.isLoading);
return (
<div className="space-y-5">
{/* Parity Counters */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<StatCard
label={t('proxy.advanced.tools.totalRequests')}
value={parity?.totalRequests ?? 0}
icon={CheckCircle}
isLoading={parityLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.tools.matchedRequests')}
value={parity?.matchedRequests ?? 0}
icon={ShieldAlert}
isLoading={parityLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.tools.parityViolations')}
value={parity?.parityViolations ?? 0}
icon={XCircle}
isLoading={parityLoading}
accent="amber"
/>
</div>
{/* IDE Quick Setup */}
<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>
<div className="p-5 space-y-3">
{isIdeLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
const instructions = getIdeInstructions(key);
return (
<div key={key} className="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" />
<span className="text-[13px] font-medium">{label}</span>
</div>
<Button
variant="ghost"
size="sm"
className="h-8 text-xs"
onClick={() => copyToClipboard(instructions)}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{t('proxy.advanced.tools.copyConfig')}
</Button>
</div>
{instructions && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
{instructions}
</pre>
)}
</div>
);
})
)}
</div>
</div>
</div>
);
};
@@ -0,0 +1,190 @@
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>
);
};
+11 -12
View File
@@ -1,18 +1,16 @@
import * as React from 'react'; import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { cva, type VariantProps } from 'class-variance-authority';
const badgeVariants = cva( const badgeVariants = cva(
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', 'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring/30',
{ {
variants: { variants: {
variant: { variant: {
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80', default: 'bg-accent/15 text-accent border border-accent/20',
secondary: secondary: 'bg-secondary text-secondary-foreground border border-white/[0.06]',
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80', destructive: 'bg-destructive/15 text-red-400 border border-destructive/20',
destructive: outline: 'border border-white/[0.1] text-muted-foreground',
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
outline: 'text-foreground',
}, },
}, },
defaultVariants: { defaultVariants: {
@@ -21,10 +19,11 @@ const badgeVariants = cva(
}, },
); );
export interface BadgeProps function Badge({
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {} className,
variant,
function Badge({ className, variant, ...props }: BadgeProps) { ...props
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />; return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
} }
+14 -11
View File
@@ -5,24 +5,27 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-[13px] font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-1 focus-visible:ring-offset-background",
{ {
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90', default:
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
destructive: destructive:
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60', 'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
outline: outline:
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50', '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 shadow-xs hover:bg-secondary/80', secondary:
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50', 'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
link: 'text-primary underline-offset-4 hover:underline', ghost:
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
link: 'text-accent underline-offset-4 hover:underline',
}, },
size: { size: {
default: 'h-9 px-4 py-2 has-[>svg]:px-3', default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5', sm: 'h-8 rounded-md gap-1.5 px-3 text-xs',
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', lg: 'h-10 rounded-lg px-6',
icon: 'size-9', icon: 'size-8 rounded-lg',
}, },
}, },
defaultVariants: { defaultVariants: {
+6 -3
View File
@@ -5,7 +5,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<div <div
ref={ref} ref={ref}
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)} className={cn(
'bg-card text-card-foreground rounded-xl border border-white/[0.06] shadow-sm transition-colors duration-200',
className,
)}
{...props} {...props}
/> />
), ),
@@ -23,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h3 <h3
ref={ref} ref={ref}
className={cn('leading-none font-semibold tracking-tight', className)} className={cn('text-sm font-semibold leading-none tracking-tight', className)}
{...props} {...props}
/> />
), ),
@@ -34,7 +37,7 @@ const CardDescription = React.forwardRef<
HTMLParagraphElement, HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement> React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} /> <p ref={ref} className={cn('text-muted-foreground text-[13px]', className)} {...props} />
)); ));
CardDescription.displayName = 'CardDescription'; CardDescription.displayName = 'CardDescription';
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root <CheckboxPrimitive.Root
ref={ref} ref={ref}
className={cn( className={cn(
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', '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',
className, className,
)} )}
{...props} {...props}
+12 -7
View File
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import * as DialogPrimitive from '@radix-ui/react-dialog'; import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons'; import { X } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -19,7 +19,8 @@ const DialogOverlay = React.forwardRef<
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
ref={ref} ref={ref}
className={cn( className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80', 'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className, className,
)} )}
{...props} {...props}
@@ -36,14 +37,18 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content <DialogPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'bg-background 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%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg', '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',
'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%]',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none"> <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">
<Cross2Icon className="h-4 w-4" /> <X className="h-4 w-4" />
<span className="sr-only">Close</span> <span className="sr-only">Close</span>
</DialogPrimitive.Close> </DialogPrimitive.Close>
</DialogPrimitive.Content> </DialogPrimitive.Content>
@@ -70,7 +75,7 @@ const DialogTitle = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Title <DialogPrimitive.Title
ref={ref} ref={ref}
className={cn('text-lg leading-none font-semibold tracking-tight', className)} className={cn('text-base leading-none font-semibold tracking-tight', className)}
{...props} {...props}
/> />
)); ));
@@ -82,7 +87,7 @@ const DialogDescription = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DialogPrimitive.Description <DialogPrimitive.Description
ref={ref} ref={ref}
className={cn('text-muted-foreground text-sm', className)} className={cn('text-muted-foreground text-[13px]', className)}
{...props} {...props}
/> />
)); ));
+8 -8
View File
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger <DropdownMenuPrimitive.SubTrigger
ref={ref} ref={ref}
className={cn( className={cn(
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none', '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]',
inset && 'pl-8', inset && 'pl-8',
className, className,
)} )}
@@ -39,7 +39,7 @@ const DropdownMenuSubContent = React.forwardRef<
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
ref={ref} ref={ref}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg', 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] p-1 shadow-xl shadow-black/20',
className, className,
)} )}
{...props} {...props}
@@ -72,7 +72,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset} sideOffset={sideOffset}
collisionPadding={collisionPadding} collisionPadding={collisionPadding}
className={cn( className={cn(
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md', 'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-lg border border-white/[0.08] p-1 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', 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className, className,
)} )}
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item <DropdownMenuPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50', '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',
inset && 'pl-8', inset && 'pl-8',
className, className,
)} )}
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem <DropdownMenuPrimitive.CheckboxItem
ref={ref} ref={ref}
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50', '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',
className, className,
)} )}
checked={checked} checked={checked}
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem <DropdownMenuPrimitive.RadioItem
ref={ref} ref={ref}
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50', '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',
className, className,
)} )}
{...props} {...props}
@@ -154,7 +154,7 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => ( >(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label <DropdownMenuPrimitive.Label
ref={ref} ref={ref}
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)} className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
{...props} {...props}
/> />
)); ));
@@ -166,7 +166,7 @@ const DropdownMenuSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator <DropdownMenuPrimitive.Separator
ref={ref} ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)} className={cn('-mx-1 my-1 h-px bg-white/[0.06]', className)}
{...props} {...props}
/> />
)); ));
+7 -4
View File
@@ -1,15 +1,18 @@
import * as React from 'react'; import * as React from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {} const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => { ({ className, type, ...props }, ref) => {
return ( return (
<input <input
type={type} type={type}
className={cn( className={cn(
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', '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',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
'disabled:cursor-not-allowed disabled:opacity-50',
className, className,
)} )}
ref={ref} ref={ref}
+1 -1
View File
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const labelVariants = cva( const labelVariants = cva(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', 'text-[13px] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
); );
const Label = React.forwardRef< const Label = React.forwardRef<
+17 -9
View File
@@ -19,14 +19,18 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger <SelectPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1', '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',
'placeholder:text-muted-foreground',
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className, className,
)} )}
{...props} {...props}
> >
{children} {children}
<SelectPrimitive.Icon asChild> <SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-3.5 w-3.5 opacity-40" />
</SelectPrimitive.Icon> </SelectPrimitive.Icon>
</SelectPrimitive.Trigger> </SelectPrimitive.Trigger>
)); ));
@@ -41,7 +45,7 @@ const SelectScrollUpButton = React.forwardRef<
className={cn('flex cursor-default items-center justify-center py-1', className)} className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props} {...props}
> >
<ChevronUp className="h-4 w-4" /> <ChevronUp className="h-3.5 w-3.5" />
</SelectPrimitive.ScrollUpButton> </SelectPrimitive.ScrollUpButton>
)); ));
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName; SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
@@ -55,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
className={cn('flex cursor-default items-center justify-center py-1', className)} className={cn('flex cursor-default items-center justify-center py-1', className)}
{...props} {...props}
> >
<ChevronDown className="h-4 w-4" /> <ChevronDown className="h-3.5 w-3.5" />
</SelectPrimitive.ScrollDownButton> </SelectPrimitive.ScrollDownButton>
)); ));
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName; SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
@@ -68,7 +72,9 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content <SelectPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md', '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',
'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' && position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1', 'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className, className,
@@ -98,7 +104,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Label <SelectPrimitive.Label
ref={ref} ref={ref}
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)} className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
{...props} {...props}
/> />
)); ));
@@ -111,14 +117,16 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50', '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',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className, className,
)} )}
{...props} {...props}
> >
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center"> <span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-3.5 w-3.5 text-accent" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
@@ -133,7 +141,7 @@ const SelectSeparator = React.forwardRef<
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SelectPrimitive.Separator <SelectPrimitive.Separator
ref={ref} ref={ref}
className={cn('bg-muted -mx-1 my-1 h-px', className)} className={cn('-mx-1 my-1 h-px bg-white/[0.06]', className)}
{...props} {...props}
/> />
)); ));
+12
View File
@@ -0,0 +1,12 @@
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}
/>
);
}
export { Skeleton };
+14 -9
View File
@@ -1,26 +1,31 @@
import * as React from 'react'; import * as React from 'react';
import * as SwitchPrimitive from '@radix-ui/react-switch'; import * as SwitchPrimitives from '@radix-ui/react-switch';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
const Switch = React.forwardRef< const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>, React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root> React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => ( >(({ className, ...props }, ref) => (
<SwitchPrimitive.Root <SwitchPrimitives.Root
className={cn( className={cn(
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50', 'peer 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',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
className, className,
)} )}
{...props} {...props}
ref={ref} ref={ref}
> >
<SwitchPrimitive.Thumb <SwitchPrimitives.Thumb
className={cn( className={cn(
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0', 'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200',
'data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5',
)} )}
/> />
</SwitchPrimitive.Root> </SwitchPrimitives.Root>
)); ));
Switch.displayName = SwitchPrimitive.Root.displayName; Switch.displayName = SwitchPrimitives.Root.displayName;
export { Switch }; export { Switch };
+7 -3
View File
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List <TabsPrimitive.List
ref={ref} ref={ref}
className={cn( className={cn(
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1', 'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
className, className,
)} )}
{...props} {...props}
@@ -26,7 +26,11 @@ const TabsTrigger = React.forwardRef<
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
ref={ref} ref={ref}
className={cn( className={cn(
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm', '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',
'disabled:pointer-events-none disabled:opacity-50',
className, className,
)} )}
{...props} {...props}
@@ -41,7 +45,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content <TabsPrimitive.Content
ref={ref} ref={ref}
className={cn( className={cn(
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none', 'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
className, className,
)} )}
{...props} {...props}
+4 -1
View File
@@ -17,7 +17,10 @@ const TooltipContent = React.forwardRef<
ref={ref} ref={ref}
sideOffset={sideOffset} sideOffset={sideOffset}
className={cn( className={cn(
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md', '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',
'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',
className, className,
)} )}
{...props} {...props}
+70
View File
@@ -0,0 +1,70 @@
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { Button } from '@/components/ui/button';
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
interface ChartStatesProps {
isLoading: boolean;
error: Error | null;
isEmpty: boolean;
onRetry: () => void;
title: string;
emptyTitle: string;
emptyDescription: string;
errorMessage: string;
retryLabel: string;
children: React.ReactNode;
}
export const ChartCard: React.FC<ChartStatesProps> = ({
isLoading,
error,
isEmpty,
onRetry,
title,
emptyTitle,
emptyDescription,
errorMessage,
retryLabel,
children,
}) => {
return (
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
<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>
</div>
<div className="flex-1">
{isLoading ? (
<div className="flex h-[280px] flex-col gap-3">
<Skeleton className="h-full w-full rounded-lg" />
</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>
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
{retryLabel}
</Button>
</div>
) : isEmpty ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
<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" />
</div>
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
{emptyDescription}
</p>
</div>
) : (
children
)}
</div>
</div>
</div>
);
};
+82
View File
@@ -0,0 +1,82 @@
/** Premium chart palette — refined for dark mode contrast */
export const COLOR_PROMPT = '#60a5fa'; // blue-400
export const COLOR_COMPLETION = '#34d399'; // emerald-400
export const COLOR_TOTAL = '#fbbf24'; // amber-400
export const PIE_COLORS = [
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
];
export const MODEL_COLORS: Record<string, string> = {
'gemini-3-flash': '#60a5fa',
'gemini-3.1-pro-low': '#818cf8',
'gemini-3.1-pro-high': '#a78bfa',
'claude-sonnet-4-6-thinking': '#f472b6',
'claude-opus-4-6-thinking': '#fb7185',
};
const colorMap: Record<string, string> = {
Prompt: COLOR_PROMPT,
Completion: COLOR_COMPLETION,
Total: COLOR_TOTAL,
};
export function BarTooltip({ indexValue, data }: any) {
const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]);
return (
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
<div className="flex flex-col gap-1">
{keys.map((key: string) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: colorMap[key] }}
/>
<span className="text-muted-foreground">{key}:</span>
<span className="text-foreground font-mono font-medium">
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
</span>
</div>
))}
</div>
</div>
);
}
export function PieTooltip({ datum }: any) {
return (
<div className="bg-popover 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="text-muted-foreground">{datum.id}:</span>
<span className="text-foreground font-mono font-medium">
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
</span>
</div>
</div>
);
}
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>
<div className="flex items-center gap-2 text-xs">
<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}
</span>
</div>
</div>
);
}
+170
View File
@@ -0,0 +1,170 @@
import React from 'react';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface TrendInfo {
/** Percentage change vs previous period */
value: number;
direction: 'up' | 'down' | 'neutral';
}
interface StatCardProps {
label: string;
value: number | string;
icon: LucideIcon;
isLoading?: boolean;
className?: string;
accent?: 'blue' | 'green' | 'amber' | 'slate' | 'purple';
trend?: TrendInfo;
sparkline?: number[];
}
const ACCENT_ICON = {
blue: 'text-blue-400',
green: 'text-emerald-400',
amber: 'text-amber-400',
slate: 'text-muted-foreground',
purple: 'text-violet-400',
};
const ACCENT_BG = {
blue: 'bg-blue-500/8',
green: 'bg-emerald-500/8',
amber: 'bg-amber-500/8',
slate: 'bg-white/[0.04]',
purple: 'bg-violet-500/8',
};
/**
* Render a tiny inline SVG sparkline from an array of values.
* Normalized to fit within a 60x20 viewBox.
*/
function Sparkline({ data, accent }: { data: number[]; accent: string }) {
if (data.length < 2) {
return null;
}
const max = Math.max(...data);
const min = Math.min(...data);
const range = max - min || 1;
const w = 60;
const h = 20;
const pad = 1;
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - pad - ((v - min) / range) * (h - pad * 2);
return `${x},${y}`;
});
const strokeColor =
accent === 'blue'
? '#60a5fa'
: accent === 'green'
? '#34d399'
: accent === 'amber'
? '#fbbf24'
: accent === 'purple'
? '#a78bfa'
: '#6b7280';
return (
<svg
viewBox={`0 0 ${w} ${h}`}
className="h-5 w-[60px] overflow-visible"
preserveAspectRatio="none"
>
<defs>
<linearGradient id={`sparkGrad-${accent}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
</linearGradient>
</defs>
{/* Area fill */}
<polygon
points={`0,${h} ${points.join(' ')} ${w},${h}`}
fill={`url(#sparkGrad-${accent})`}
/>
{/* Line */}
<polyline
points={points.join(' ')}
fill="none"
stroke={strokeColor}
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
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';
return (
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
<Icon className="h-3 w-3" />
<span className="tabular-nums">{Math.abs(trend.value).toFixed(1)}%</span>
</div>
);
}
export const StatCard: React.FC<StatCardProps> = ({
label,
value,
icon: Icon,
isLoading,
className,
accent = 'slate',
trend,
sparkline,
}) => {
return (
<div
className={cn(
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
'hover:border-white/[0.1] hover:bg-white/[0.02]',
className,
)}
>
<div className="flex items-start justify-between">
<div className="flex flex-col gap-1.5">
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
{label}
</span>
{isLoading ? (
<Skeleton className="h-7 w-20" />
) : (
<div className="flex items-baseline gap-2">
<span className="text-foreground font-mono text-xl font-semibold tracking-tight tabular-nums">
{typeof value === 'number' ? value.toLocaleString() : value}
</span>
{trend && <TrendBadge trend={trend} />}
</div>
)}
</div>
<div className="flex flex-col items-end gap-2">
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
ACCENT_BG[accent],
)}
>
<Icon className={cn('h-4 w-4', ACCENT_ICON[accent])} />
</div>
{sparkline && sparkline.length >= 2 && !isLoading && (
<Sparkline data={sparkline} accent={accent} />
)}
</div>
</div>
</div>
);
};
+2 -2
View File
@@ -151,7 +151,7 @@ export function useSetAccountProxy() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
}, },
onError: (error: any) => { onError: (error: unknown) => {
console.error('[Mutation] setAccountProxy failed:', error); console.error('[Mutation] setAccountProxy failed:', error);
}, },
}); });
@@ -176,7 +176,7 @@ export function useImportCloudAccounts() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts }); queryClient.invalidateQueries({ queryKey: QUERY_KEYS.cloudAccounts });
}, },
onError: (error: any) => { onError: (error: unknown) => {
console.error('[Mutation] importCloudAccounts failed:', error); console.error('[Mutation] importCloudAccounts failed:', error);
}, },
}); });
+8 -8
View File
@@ -393,8 +393,8 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
await clearAccountStatus(account); await clearAccountStatus(account);
notifyTrayUpdate(account); notifyTrayUpdate(account);
return account; return account;
} catch (error: any) { } catch (error: unknown) {
if (error.message === 'UNAUTHORIZED') { if (extractErrorMessage(error) === 'UNAUTHORIZED') {
logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`); logger.warn(`Got 401 Unauthorized for ${account.email}, forcing token refresh...`);
try { try {
const newTokenData = await GoogleAPIService.refreshAccessToken( const newTokenData = await GoogleAPIService.refreshAccessToken(
@@ -462,7 +462,7 @@ export async function refreshAccountQuota(accountId: string): Promise<CloudAccou
await markAccountStatusFromError(account, refreshError); await markAccountStatusFromError(account, refreshError);
throw refreshError; throw refreshError;
} }
} else if (error.message === 'FORBIDDEN') { } else if (extractErrorMessage(error) === 'FORBIDDEN') {
logger.warn( logger.warn(
`Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`, `Got 403 Forbidden for ${account.email}, marking as rate limited (if implemented) or just ignoring.`,
); );
@@ -574,9 +574,9 @@ export async function switchCloudAccount(accountId: string): Promise<void> {
notifyTrayUpdate(account); notifyTrayUpdate(account);
}, },
}); });
} catch (err: any) { } catch (err: unknown) {
logger.error('Failed to switch cloud account', err); logger.error('Failed to switch cloud account', err);
throw new Error(`Switch failed: ${err.message || 'Unknown error'}`); throw new Error(`Switch failed: ${extractErrorMessage(err) || 'Unknown error'}`);
} }
}); });
} }
@@ -685,7 +685,7 @@ export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
// Trigger an immediate check if enabled // Trigger an immediate check if enabled
if (enabled) { if (enabled) {
const { CloudMonitorService } = await import('../../services/CloudMonitorService'); const { CloudMonitorService } = await import('../../services/CloudMonitorService');
CloudMonitorService.poll().catch((err: any) => CloudMonitorService.poll().catch((err: unknown) =>
logger.error('Failed to poll after enabling auto-switch', err), logger.error('Failed to poll after enabling auto-switch', err),
); );
} }
@@ -834,8 +834,8 @@ export async function importCloudAccounts(
await CloudAccountRepo.addAccount(newAccount); await CloudAccountRepo.addAccount(newAccount);
result.imported++; result.imported++;
} }
} catch (error: any) { } catch (error: unknown) {
result.errors.push(`Failed to import ${acc.email}: ${error.message}`); result.errors.push(`Failed to import ${acc.email}: ${extractErrorMessage(error)}`);
} }
} }
+8 -8
View File
@@ -192,8 +192,8 @@ export const cloudRouter = os.router({
.handler(async ({ input }) => { .handler(async ({ input }) => {
try { try {
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl); await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
} catch (error: any) { } catch (error: unknown) {
logger.error('[ORPC] setAccountProxy error:', error.message, error.stack); logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error; throw error;
} }
}), }),
@@ -203,8 +203,8 @@ export const cloudRouter = os.router({
const result = await CloudAccountRepo.syncFromIDE(); const result = await CloudAccountRepo.syncFromIDE();
return result; return result;
} catch (error: any) { } catch (error: unknown) {
logger.error('[ORPC] syncLocalAccount error:', error.message, error.stack); logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw toSyncLocalAccountORPCError(error); throw toSyncLocalAccountORPCError(error);
} }
}), }),
@@ -273,8 +273,8 @@ export const cloudRouter = os.router({
.handler(async ({ input }) => { .handler(async ({ input }) => {
try { try {
return await exportCloudAccounts(input.stripTokens); return await exportCloudAccounts(input.stripTokens);
} catch (error: any) { } catch (error: unknown) {
logger.error('[ORPC] exportCloudAccounts error:', error.message, error.stack); logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error; throw error;
} }
}), }),
@@ -297,8 +297,8 @@ export const cloudRouter = os.router({
.handler(async ({ input }) => { .handler(async ({ input }) => {
try { try {
return await importCloudAccounts(input.jsonContent, input.strategy); return await importCloudAccounts(input.jsonContent, input.strategy);
} catch (error: any) { } catch (error: unknown) {
logger.error('[ORPC] importCloudAccounts error:', error.message, error.stack); logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
throw error; throw error;
} }
}), }),
+50 -5
View File
@@ -59,7 +59,7 @@ function sleepSync(ms: number): void {
* Ensures that the cloud database file and schema exist. * Ensures that the cloud database file and schema exist.
* @param dbPath {string} The path to the database file. * @param dbPath {string} The path to the database file.
*/ */
function ensureDatabaseInitialized(dbPath: string): void { export function ensureDatabaseInitialized(dbPath: string): void {
const dir = path.dirname(dbPath); const dir = path.dirname(dbPath);
if (!fs.existsSync(dir)) { if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true }); fs.mkdirSync(dir, { recursive: true });
@@ -68,6 +68,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
let db: Database.Database | null = null; let db: Database.Database | null = null;
try { try {
db = new Database(dbPath); db = new Database(dbPath);
db.pragma('journal_mode = WAL');
configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS }); configureDatabase(db, { busyTimeoutMs: SQLITE_BUSY_TIMEOUT_MS });
// Create accounts table // Create accounts table
@@ -126,6 +127,44 @@ function ensureDatabaseInitialized(dbPath: string): void {
value TEXT NOT NULL value TEXT NOT NULL
); );
`); `);
// Create token_usage table
db.exec(`
CREATE TABLE IF NOT EXISTS token_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id TEXT NOT NULL,
model TEXT NOT NULL,
prompt_tokens INTEGER NOT NULL DEFAULT 0,
completion_tokens INTEGER NOT NULL DEFAULT 0,
total_tokens INTEGER NOT NULL DEFAULT 0,
timestamp INTEGER NOT NULL,
request_type TEXT
);
`);
// Migration: Check if request_type column exists on token_usage
const tokenUsageTableInfoRaw = db.pragma('table_info(token_usage)') as any[];
const tokenUsageTableInfo = parseRows(
TableInfoRowSchema,
tokenUsageTableInfoRaw,
'cloud.token_usage.tableInfo',
);
const hasRequestType = tokenUsageTableInfo.some((col) => col.name === 'request_type');
if (!hasRequestType) {
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT');
}
const hasIsEstimated = tokenUsageTableInfo.some((col) => col.name === 'is_estimated');
if (!hasIsEstimated) {
db.exec('ALTER TABLE token_usage ADD COLUMN is_estimated INTEGER');
}
// Create indexes for token_usage
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_timestamp ON token_usage(timestamp);`);
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_model ON token_usage(model);`);
db.exec(
`CREATE INDEX IF NOT EXISTS idx_token_usage_account_timestamp ON token_usage(account_id, timestamp);`,
);
} catch (error) { } catch (error) {
logger.error('Failed to initialize cloud database schema', error); logger.error('Failed to initialize cloud database schema', error);
throw error; throw error;
@@ -137,7 +176,7 @@ function ensureDatabaseInitialized(dbPath: string): void {
/** /**
* Gets a connection to the cloud accounts database. * Gets a connection to the cloud accounts database.
*/ */
function getCloudDb(): { export function getCloudDb(): {
raw: Database.Database; raw: Database.Database;
orm: BetterSQLite3Database<typeof drizzleSchema>; orm: BetterSQLite3Database<typeof drizzleSchema>;
} { } {
@@ -521,12 +560,18 @@ export class CloudAccountRepo {
try { try {
const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all(); const rows = orm.select().from(accounts).orderBy(desc(accounts.lastUsed)).all();
// DEBUG LOGS
const activeRows = rows.filter((r) => r.isActive); const activeRows = rows.filter((r) => r.isActive);
logger.debug(`getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`);
// Auto-activate the single account if none are active
if (rows.length > 0 && activeRows.length === 0) {
const targetRow = rows[0];
logger.info( logger.info(
`[DEBUG] getAccounts: Found ${rows.length} accounts, ${activeRows.length} active.`, `No active account found. Auto-activating account: ${targetRow.email} (${targetRow.id})`,
); );
activeRows.forEach((r) => logger.info(`[DEBUG] Active Account: ${r.email} (${r.id})`)); orm.update(accounts).set({ isActive: 1 }).where(eq(accounts.id, targetRow.id)).run();
targetRow.isActive = 1;
}
const cloudAccounts: CloudAccount[] = []; const cloudAccounts: CloudAccount[] = [];
for (const normalizedRow of rows) { for (const normalizedRow of rows) {
+452
View File
@@ -0,0 +1,452 @@
import { logger } from '../../utils/logger';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { proxyRequestMetrics, proxyConnectionSnapshots, trafficLogs } from './schema';
export interface RecordProxyRequestParams {
endpoint: string;
statusCode: number;
durationMs: number;
timestamp: number;
accountId?: string | null;
tokensPrompt?: number | null;
tokensCompletion?: number | null;
errorMessage?: string | null;
}
let proxyMetricsDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getProxyMetricsDb() {
if (!proxyMetricsDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
const conn = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
ensureProxyMetricsTable(conn.raw);
proxyMetricsDbConnection = conn;
}
return proxyMetricsDbConnection;
}
export function ensureProxyMetricsTable(rawDb: Database.Database): void {
try {
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_request_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
endpoint TEXT NOT NULL,
status_code INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
account_id TEXT,
tokens_prompt INTEGER,
tokens_completion INTEGER,
error_message TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_timestamp ON proxy_request_metrics(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_metrics_status ON proxy_request_metrics(status_code);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS proxy_connection_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
count INTEGER NOT NULL DEFAULT 0
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_proxy_conn_timestamp ON proxy_connection_snapshots(timestamp);`,
);
rawDb.exec(`
CREATE TABLE IF NOT EXISTS traffic_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp INTEGER NOT NULL,
direction TEXT NOT NULL,
request_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
method TEXT,
status INTEGER,
headers_json TEXT,
body_json TEXT,
chunk_json TEXT,
duration_ms INTEGER,
error_message TEXT,
error_stack TEXT,
error_status INTEGER,
metadata_json TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
);
} catch (error) {
logger.error('Failed to ensure proxy_request_metrics table', error);
throw error;
}
}
export class ProxyMetricsRepo {
static recordRequest(params: RecordProxyRequestParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(proxyRequestMetrics)
.values({
endpoint: params.endpoint,
statusCode: params.statusCode,
durationMs: params.durationMs,
timestamp: params.timestamp,
accountId: params.accountId ?? null,
tokensPrompt: params.tokensPrompt ?? null,
tokensCompletion: params.tokensCompletion ?? null,
errorMessage: params.errorMessage ?? null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record proxy request metric', error);
return false;
}
}
static getRecentAggregates(): {
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const oneMinuteAgo = now - 60_000;
const fiveMinutesAgo = now - 5 * 60_000;
try {
const requestsPerMinuteRow = raw
.prepare(`SELECT COUNT(*) as count FROM proxy_request_metrics WHERE timestamp >= ?`)
.get(oneMinuteAgo) as { count: number } | undefined;
const requestsPerMinute = requestsPerMinuteRow?.count ?? 0;
const aggregatesRow = raw
.prepare(
`SELECT
COUNT(*) as total,
AVG(duration_ms) as avgLatency,
CASE
WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics
WHERE timestamp >= ?`,
)
.get(fiveMinutesAgo) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const avgLatencyMs = aggregatesRow?.avgLatency ? Math.round(aggregatesRow.avgLatency) : 0;
const errorRatePercent = aggregatesRow?.errorRate ?? 0;
return {
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
};
} catch (error) {
logger.error('Failed to get recent proxy metrics aggregates', error);
return {
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
};
}
}
static recordConnectionSnapshot(count: number): boolean {
const { orm } = getProxyMetricsDb();
try {
orm.insert(proxyConnectionSnapshots).values({ timestamp: Date.now(), count }).run();
return true;
} catch (error) {
logger.error('Failed to record connection snapshot', error);
return false;
}
}
static getHistoricalAggregates(windowMs: number = 60 * 60 * 1000): {
totalRequests: number;
requestsPerMinute: number;
avgLatencyMs: number;
errorRatePercent: number;
avgActiveConnections: number;
} {
const { raw } = getProxyMetricsDb();
const now = Date.now();
const cutoff = now - windowMs;
try {
const reqRow = raw
.prepare(
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics WHERE timestamp >= ?`,
)
.get(cutoff) as
| { total: number; avgLatency: number | null; errorRate: number }
| undefined;
const totalRequests = reqRow?.total ?? 0;
const minutes = Math.max(windowMs / 60_000, 1);
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
const errorRatePercent = reqRow?.errorRate ?? 0;
const connRow = raw
.prepare(`SELECT AVG(count) as avg FROM proxy_connection_snapshots WHERE timestamp >= ?`)
.get(cutoff) as { avg: number | null } | undefined;
const avgActiveConnections = connRow?.avg ? Math.round(connRow.avg) : 0;
return {
totalRequests,
requestsPerMinute,
avgLatencyMs,
errorRatePercent,
avgActiveConnections,
};
} catch (error) {
logger.error('Failed to get historical proxy metrics aggregates', error);
return {
totalRequests: 0,
requestsPerMinute: 0,
avgLatencyMs: 0,
errorRatePercent: 0,
avgActiveConnections: 0,
};
}
}
static deleteOldRecords(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old proxy metrics records', error);
return false;
}
}
}
export interface InsertTrafficLogParams {
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}
export class TrafficLogsRepo {
static insert(params: InsertTrafficLogParams): boolean {
const { orm } = getProxyMetricsDb();
try {
orm
.insert(trafficLogs)
.values({
timestamp: params.timestamp,
direction: params.direction,
requestId: params.requestId,
endpoint: params.endpoint,
method: params.method ?? null,
status: params.status ?? null,
headersJson: params.headers ? JSON.stringify(params.headers) : null,
bodyJson: params.body != null ? JSON.stringify(params.body) : null,
chunkJson: params.chunk != null ? JSON.stringify(params.chunk) : null,
durationMs: params.durationMs ?? null,
errorMessage: params.error?.message ?? null,
errorStack: params.error?.stack ?? null,
errorStatus: params.error?.status ?? null,
metadataJson: params.metadata ? JSON.stringify(params.metadata) : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to insert traffic log', error);
return false;
}
}
static getRecent(limit: number = 50, direction?: string): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
let sql = `SELECT * FROM traffic_logs`;
const args: (number | string)[] = [];
if (direction) {
sql += ` WHERE direction = ?`;
args.push(direction);
}
sql += ` ORDER BY timestamp DESC LIMIT ?`;
args.push(limit);
const rows = raw.prepare(sql).all(...args) as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return [];
}
}
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
direction: string;
endpoint: string;
cnt: number;
}>;
const byDirection: Record<string, number> = {};
const byEndpoint: Record<string, number> = {};
let total = 0;
for (const r of rows) {
total += r.cnt;
byDirection[r.direction] = (byDirection[r.direction] || 0) + r.cnt;
byEndpoint[r.endpoint] = (byEndpoint[r.endpoint] || 0) + r.cnt;
}
return { byDirection, byEndpoint, total };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { byDirection: {}, byEndpoint: {}, total: 0 };
}
}
static getAllForExport(): Array<{
timestamp: number;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: { message: string; stack?: string; status?: number; body?: unknown };
metadata?: Record<string, unknown>;
}> {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
timestamp: number;
direction: string;
request_id: string;
endpoint: string;
method: string | null;
status: number | null;
headers_json: string | null;
body_json: string | null;
chunk_json: string | null;
duration_ms: number | null;
error_message: string | null;
error_stack: string | null;
error_status: number | null;
metadata_json: string | null;
}>;
return rows.map((r) => ({
timestamp: r.timestamp,
direction: r.direction,
requestId: r.request_id,
endpoint: r.endpoint,
method: r.method ?? undefined,
status: r.status ?? undefined,
headers: r.headers_json ? JSON.parse(r.headers_json) : undefined,
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
logger.error('Failed to get all traffic logs for export', error);
return [];
}
}
static deleteOld(cutoffTimestamp: number): boolean {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM traffic_logs WHERE timestamp < ?`).run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old traffic logs', error);
return false;
}
}
}
+48
View File
@@ -27,3 +27,51 @@ export const itemTable = sqliteTable('ItemTable', {
key: text('key').primaryKey(), key: text('key').primaryKey(),
value: text('value'), value: text('value'),
}); });
export const tokenUsage = sqliteTable('token_usage', {
id: integer('id').primaryKey({ autoIncrement: true }),
accountId: text('account_id').notNull(),
model: text('model').notNull(),
promptTokens: integer('prompt_tokens').notNull().default(0),
completionTokens: integer('completion_tokens').notNull().default(0),
totalTokens: integer('total_tokens').notNull().default(0),
timestamp: integer('timestamp').notNull(),
requestType: text('request_type'),
isEstimated: integer('is_estimated'),
});
export const proxyRequestMetrics = sqliteTable('proxy_request_metrics', {
id: integer('id').primaryKey({ autoIncrement: true }),
endpoint: text('endpoint').notNull(),
statusCode: integer('status_code').notNull(),
durationMs: integer('duration_ms').notNull(),
timestamp: integer('timestamp').notNull(),
accountId: text('account_id'),
tokensPrompt: integer('tokens_prompt'),
tokensCompletion: integer('tokens_completion'),
errorMessage: text('error_message'),
});
export const proxyConnectionSnapshots = sqliteTable('proxy_connection_snapshots', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
count: integer('count').notNull().default(0),
});
export const trafficLogs = sqliteTable('traffic_logs', {
id: integer('id').primaryKey({ autoIncrement: true }),
timestamp: integer('timestamp').notNull(),
direction: text('direction').notNull(),
requestId: text('request_id').notNull(),
endpoint: text('endpoint').notNull(),
method: text('method'),
status: integer('status'),
headersJson: text('headers_json'),
bodyJson: text('body_json'),
chunkJson: text('chunk_json'),
durationMs: integer('duration_ms'),
errorMessage: text('error_message'),
errorStack: text('error_stack'),
errorStatus: integer('error_status'),
metadataJson: text('metadata_json'),
});
+274
View File
@@ -0,0 +1,274 @@
import { logger } from '../../utils/logger';
import Database from 'better-sqlite3';
import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3';
import { openDrizzleConnection } from './dbConnection';
import { getCloudAccountsDbPath } from '../../utils/paths';
import { ensureDatabaseInitialized } from './cloudHandler';
import { tokenUsage } from './schema';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
export interface RecordUsageParams {
accountId: string;
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
timestamp: number;
requestType?: string;
isEstimated?: boolean;
}
let usageDbConnection: {
raw: Database.Database;
orm: BetterSQLite3Database<typeof import('./schema')>;
} | null = null;
function getUsageDb() {
if (!usageDbConnection) {
const dbPath = getCloudAccountsDbPath();
ensureDatabaseInitialized(dbPath);
usageDbConnection = openDrizzleConnection(
dbPath,
{ readonly: false, fileMustExist: false },
{ busyTimeoutMs: 3000 },
);
}
return usageDbConnection;
}
function buildDateFilter(start?: number, end?: number): { sql: string; params: number[] } {
const conditions: string[] = [];
const params: number[] = [];
if (start !== undefined) {
conditions.push(`timestamp >= ?`);
params.push(start);
}
if (end !== undefined) {
conditions.push(`timestamp <= ?`);
params.push(end);
}
return {
sql: conditions.length > 0 ? `AND ${conditions.join(' AND ')}` : '',
params,
};
}
function buildAccountFilter(accountId?: string): { sql: string; params: unknown[] } {
return accountId ? { sql: 'AND account_id = ?', params: [accountId] } : { sql: '', params: [] };
}
export class TokenUsageRepo {
static recordUsage(params: RecordUsageParams): boolean {
const { orm } = getUsageDb();
try {
orm
.insert(tokenUsage)
.values({
accountId: params.accountId,
model: params.model,
promptTokens: params.promptTokens,
completionTokens: params.completionTokens,
totalTokens: params.totalTokens,
timestamp: params.timestamp,
requestType: params.requestType ?? null,
isEstimated: params.isEstimated ? 1 : null,
})
.run();
return true;
} catch (error) {
logger.error('Failed to record token usage', error);
return false;
}
}
static getUsageByHour(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d %H:00', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => HourlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get hourly usage', error);
return [];
}
}
static getUsageByDay(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m-%d', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => DailyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get daily usage', error);
return [];
}
}
static getUsageByWeek(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%W', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => WeeklyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get weekly usage', error);
return [];
}
}
static getUsageByMonth(
accountId?: string,
start?: number,
end?: number,
): Array<{
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
strftime('%Y-%m', timestamp / 1000, 'unixepoch', 'localtime') as bucket,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY bucket
ORDER BY bucket
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => MonthlyUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get monthly usage', error);
return [];
}
}
static getUsageByModel(
accountId?: string,
start?: number,
end?: number,
): Array<{
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}> {
const { raw } = getUsageDb();
try {
const accountFilter = buildAccountFilter(accountId);
const dateFilter = buildDateFilter(start, end);
const sql = `
SELECT
model,
SUM(prompt_tokens) as promptTokens,
SUM(completion_tokens) as completionTokens,
SUM(total_tokens) as totalTokens,
COUNT(*) as requests
FROM token_usage
WHERE 1=1
${accountFilter.sql}
${dateFilter.sql}
GROUP BY model
ORDER BY totalTokens DESC
`;
const rows = raw.prepare(sql).all(...accountFilter.params, ...dateFilter.params) as unknown[];
return rows.map((row) => ModelUsageSchema.parse(row));
} catch (error) {
logger.error('Failed to get model usage', error);
return [];
}
}
}
+26 -502
View File
@@ -1,70 +1,10 @@
import { exec, execSync, spawn } from 'child_process';
import { promisify } from 'util';
import findProcess, { ProcessInfo } from 'find-process';
import { isNumber } from 'lodash-es';
import { getGeminiNexusExecutablePath, isWsl } from '../../utils/paths';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
import {
const execAsync = promisify(exec); bootstrapNestServer,
const PROCESS_STARTUP_TIMEOUT_MS = 6000; isNestServerRunning,
const PROCESS_STARTUP_POLL_INTERVAL_MS = 200; stopNestServer,
const LINUX_GPU_SAFE_LAUNCH_ARGS = ['--disable-gpu', '--disable-gpu-compositing'] as const; } from '../../server/main';
import { ConfigManager } from '../config/manager';
/**
* Helper process name patterns to exclude (Electron helper processes)
*/
const HELPER_PATTERNS = [
'helper',
'plugin',
'renderer',
'gpu',
'crashpad',
'utility',
'audio',
'sandbox',
'language_server',
];
/**
* Check if a process is a helper/auxiliary process that should be excluded.
* @param name Process name (lowercase)
* @param cmd Process command line (lowercase)
* @returns True if the process is a helper process
*/
function isHelperProcess(name: string, cmd: string): boolean {
const nameLower = name.toLowerCase();
const cmdLower = cmd.toLowerCase();
// Check for --type= argument (Electron helper process indicator)
if (cmdLower.includes('--type=')) {
return true;
}
// Check for helper patterns in process name
for (const pattern of HELPER_PATTERNS) {
if (nameLower.includes(pattern)) {
return true;
}
}
// Check for crashpad in path
if (cmdLower.includes('crashpad')) {
return true;
}
return false;
}
function isPgrepNoMatchError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const message = error.message.toLowerCase();
const hasPgrep = message.includes('pgrep') && message.includes('geminiNexus');
const code = (error as { code?: number }).code;
return hasPgrep && code === 1;
}
/** /**
* Checks if the Gemini Nexus process is running. * Checks if the Gemini Nexus process is running.
@@ -72,122 +12,8 @@ function isPgrepNoMatchError(error: unknown): boolean {
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise. * @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
*/ */
export async function isProcessRunning(): Promise<boolean> { export async function isProcessRunning(): Promise<boolean> {
try { // Check the embedded NestJS proxy server status instead of an external process
const platform = process.platform; return isNestServerRunning();
const currentPid = process.pid;
// Use find-process to search for Gemini Nexus processes
const allMatches: ProcessInfo[] = [];
const searchNames = ['Gemini Nexus', 'geminiNexus'];
if (platform === 'linux') {
searchNames.push('electron');
}
let sawNoMatch = false;
for (const searchName of searchNames) {
try {
const matches = await findProcess('name', searchName, false);
allMatches.push(...matches);
} catch (error) {
if (isPgrepNoMatchError(error)) {
sawNoMatch = true;
continue;
}
throw error;
}
}
const processMap = new Map<number, ProcessInfo>();
for (const proc of allMatches) {
if (isNumber(proc.pid)) {
processMap.set(proc.pid, proc);
}
}
const processes = Array.from(processMap.values());
if (processes.length === 0 && sawNoMatch) {
logger.debug('No Gemini Nexus process found (pgrep returned 1)');
}
logger.debug(`Found ${processes.length} processes matching 'GeminiNexus/geminiNexus'`);
for (const proc of processes) {
// Skip self
if (proc.pid === currentPid) {
continue;
}
const name = proc.name?.toLowerCase() || '';
const cmd = proc.cmd?.toLowerCase() || '';
// Skip manager process
if (name.includes('manager') || cmd.includes('manager') || cmd.includes('gemini-nexus')) {
continue;
}
// Skip helper processes
if (isHelperProcess(name, cmd)) {
continue;
}
if (platform === 'darwin') {
// macOS: Check for Gemini Nexus.app in path
if (cmd.includes('geminiNexus.app')) {
logger.debug(
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
// Also check if the process name is exactly 'Gemini Nexus' (main process)
if (name === 'geminiNexus' && !isHelperProcess(name, cmd)) {
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
return true;
}
} else if (platform === 'win32') {
// Windows: Check for Gemini Nexus.exe
if (name === 'geminiNexus.exe' || name === 'geminiNexus') {
logger.debug(`Found Gemini Nexus process: PID=${proc.pid}, name=${name}`);
return true;
}
} else {
const nameLower = name.toLowerCase();
const cmdLower = cmd.toLowerCase();
if (nameLower === 'electron') {
// Stricter check for AUR/Electron wrapper:
// Must include geminiNexus in command line but NOT manager or tools
const isGeminiNexusApp =
(cmdLower.includes('/geminiNexus') ||
cmdLower.includes(' geminiNexus') ||
cmdLower.endsWith('geminiNexus')) &&
!cmdLower.includes('manager') &&
!cmdLower.includes('tools');
if (isGeminiNexusApp) {
logger.debug(
`Found Gemini Nexus (AUR/electron) process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
}
if (
(name.includes('geminiNexus') || cmd.includes('/geminiNexus')) &&
!name.includes('tools')
) {
logger.debug(
`Found Gemini Nexus process: PID=${proc.pid}, name=${name}, cmd=${cmd.substring(0, 100)}`,
);
return true;
}
}
}
return false;
} catch (error) {
logger.error('Error checking process status with find-process:', error);
return false;
}
} }
/** /**
@@ -195,290 +21,26 @@ export async function isProcessRunning(): Promise<boolean> {
* @returns {boolean} True if the Gemini Nexus process is running, false otherwise. * @returns {boolean} True if the Gemini Nexus process is running, false otherwise.
*/ */
export async function closeGeminiNexus(): Promise<void> { export async function closeGeminiNexus(): Promise<void> {
logger.info('Closing Gemini Nexus...'); logger.info('Stopping Gemini Nexus proxy server...');
const platform = process.platform; await stopNestServer();
try {
// Stage 1: Graceful Shutdown (Platform specific)
if (platform === 'darwin') {
// macOS: Use AppleScript to quit gracefully
try {
logger.info('Attempting graceful exit via AppleScript...');
execSync('osascript -e \'tell application "Gemini Nexus" to quit\'', {
stdio: 'ignore',
timeout: 3000,
});
// Wait for a moment
await new Promise((resolve) => setTimeout(resolve, 2000));
} catch {
logger.warn('AppleScript exit failed, proceeding to next stage');
}
} else if (platform === 'win32') {
// Windows: Use taskkill /IM (without /F) for graceful close
try {
logger.info('Attempting graceful exit via taskkill...');
// /T = Tree (child processes), /IM = Image Name
// We do not wait long here.
execSync('taskkill /IM "GeminiNexus.exe" /T', {
stdio: 'ignore',
timeout: 2000,
});
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch {
// Ignore failure, we play hard next.
}
} }
// Stage 2 & 3: Find and Kill remaining processes
// We use a more aggressive approach here but try to avoid killing ourselves
const currentPid = process.pid;
// Helper to list processes
const getProcesses = (): { pid: number; name: string; cmd: string }[] => {
try {
let output = '';
if (platform === 'win32') {
const psCommand = (cmdlet: string) =>
`powershell -NoProfile -Command "${cmdlet} Win32_Process -Filter \\"Name like 'GeminiNexus%'\\" | Select-Object ProcessId, Name, CommandLine | ConvertTo-Csv -NoTypeInformation"`;
try {
output = execSync(psCommand('Get-CimInstance'), {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10,
stdio: ['pipe', 'pipe', 'ignore'],
});
} catch (e) {
// CIM failed (likely older OS), try WMI
try {
output = execSync(psCommand('Get-WmiObject'), {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10,
});
} catch (innerE) {
// Both failed, throw original or log? Throwing lets the outer catch handle it (returning empty list)
throw e;
}
}
} else {
// Unix/Linux/macOS
output = execSync('ps -A -o pid,comm,args', {
encoding: 'utf-8',
maxBuffer: 1024 * 1024 * 10,
});
}
const processList: { pid: number; name: string; cmd: string }[] = [];
if (platform === 'win32') {
// Parse CSV Output
const lines = output.trim().split(/\r?\n/);
// First line is headers "ProcessId","Name","CommandLine"
// We start from index 1
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) {
continue;
}
// Regex to match CSV fields: "val1","val2","val3"
const match = line.match(/^"(\d+)","(.*?)","(.*?)"$/);
if (match) {
const pid = parseInt(match[1]);
const name = match[2];
const cmdLine = match[3];
processList.push({ pid, name, cmd: cmdLine || name });
}
}
} else {
const lines = output.split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length < 2) continue;
const pid = parseInt(parts[0]);
if (isNaN(pid)) continue;
const rest = parts.slice(1).join(' ');
if (rest.includes('Gemini Nexus') || rest.includes('geminiNexus')) {
processList.push({ pid, name: parts[1], cmd: rest });
}
}
}
return processList;
} catch (e) {
logger.error('Failed to list processes', e);
return [];
}
};
const targetProcessList = getProcesses().filter((p) => {
// Exclude self
if (p.pid === currentPid) {
return false;
}
// Exclude this electron app (if named Gemini Nexus or gemini-nexus)
if (p.cmd.includes('Gemini Nexus') || p.cmd.includes('gemini-nexus')) {
return false;
}
// Match Gemini Nexus (but not manager)
if (platform === 'win32') {
return (
p.cmd.includes('GeminiNexus.exe') ||
(p.cmd.includes('geminiNexus') && !p.cmd.includes('manager'))
);
} else {
// Explicit !manager check for Linux/macOS to be defensive
return (
(p.cmd.includes('Gemini Nexus') || p.cmd.includes('geminiNexus')) &&
!p.cmd.includes('manager')
);
}
});
if (targetProcessList.length === 0) {
logger.info('No Gemini Nexus processes found running.');
return;
}
logger.info(`Found ${targetProcessList.length} remaining Gemini Nexus processes. Killing...`);
for (const p of targetProcessList) {
try {
process.kill(p.pid, 'SIGKILL'); // Force kill as final step
} catch {
// Ignore if already dead
}
}
} catch (error) {
logger.error('Error closing Gemini Nexus', error);
// Fallback to simple kill if everything fails
try {
if (platform === 'win32') {
execSync('taskkill /F /IM "GeminiNexus.exe" /T', { stdio: 'ignore' });
} else {
execSync('pkill -9 -f Gemini Nexus', { stdio: 'ignore' });
}
} catch {
// Ignore
}
}
}
/** /**
* Waits for the Gemini Nexus process to exit. * Waits for the Gemini Nexus process to exit.
* @param timeoutMs {number} The timeout in milliseconds. * Polls the NestJS server status until it stops or the timeout is reached.
* @param timeoutMs {number} Maximum time to wait in milliseconds.
* @returns {Promise<void>} A promise that resolves when the process exits. * @returns {Promise<void>} A promise that resolves when the process exits.
*/ */
export async function _waitForProcessExit( export async function _waitForProcessExit(timeoutMs: number): Promise<void> {
timeoutMs: number, const start = Date.now();
pollInterval = 100, // Make it configurable, but keep fast 100ms default while (isNestServerRunning()) {
): Promise<void> { if (Date.now() - start > timeoutMs) {
const startTime = Date.now(); throw new Error('Timeout waiting for Gemini Nexus proxy server to stop');
while (Date.now() - startTime < timeoutMs) {
if (!(await isProcessRunning())) {
return;
} }
await new Promise((resolve) => setTimeout(resolve, pollInterval)); await new Promise((resolve) => setTimeout(resolve, 100));
} }
throw new Error(`Gemini Nexus process did not exit within ${timeoutMs}ms`);
}
/**
* Opens a URI protocol.
* @param uri {string} The URI to open.
* @returns {Promise<boolean>} True if the URI was opened successfully, false otherwise.
*/
async function openUri(uri: string): Promise<boolean> {
const platform = process.platform;
const wsl = isWsl();
try {
if (platform === 'darwin') {
// macOS: use open command
await execAsync(`open "${uri}"`);
} else if (platform === 'win32') {
// Windows: use start command
await execAsync(`start "" "${uri}"`);
} else if (wsl) {
// WSL: use cmd.exe to open URI
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${uri}"`);
} else {
// Linux: use xdg-open
await execAsync(`xdg-open "${uri}"`);
}
return true;
} catch (error) {
logger.error('Failed to open URI', error);
return false;
}
}
async function waitForGeminiNexusStartup(
timeoutMs = PROCESS_STARTUP_TIMEOUT_MS,
pollIntervalMs = PROCESS_STARTUP_POLL_INTERVAL_MS,
): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (await isProcessRunning()) {
return true;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
return false;
}
function shouldUseLinuxGpuSafeLaunchArgs(): boolean {
if (process.platform !== 'linux' || isWsl()) {
return false;
}
const enableLinuxGpuRaw = process.env.ANTIGRAVITY_MANAGER_ENABLE_LINUX_GPU?.trim().toLowerCase();
return enableLinuxGpuRaw !== '1' && enableLinuxGpuRaw !== 'true';
}
async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
if (process.platform === 'darwin') {
await execAsync(`open -a Gemini Nexus`);
return;
}
if (process.platform === 'win32') {
if (!execPath) {
throw new Error('Unable to locate Gemini Nexus executable path');
}
// Use start command to detach
await execAsync(`start "" "${execPath}"`);
return;
}
if (isWsl()) {
if (!execPath) {
throw new Error('Unable to locate Gemini Nexus executable path');
}
// In WSL, convert path and use cmd.exe
const winPath = execPath
.replace(/^\/mnt\/([a-z])\//, (_, drive) => `${drive.toUpperCase()}:\\`)
.replace(/\//g, '\\');
await execAsync(`/mnt/c/Windows/System32/cmd.exe /c start "" "${winPath}"`);
return;
}
if (!execPath) {
throw new Error('Unable to locate Gemini Nexus executable path');
}
const launchArgs = shouldUseLinuxGpuSafeLaunchArgs() ? [...LINUX_GPU_SAFE_LAUNCH_ARGS] : [];
if (launchArgs.length > 0) {
logger.info(`Linux launch with GPU-safe args: ${launchArgs.join(' ')}`);
}
const child = spawn(execPath, launchArgs, {
detached: true,
stdio: 'ignore',
});
child.unref();
} }
/** /**
@@ -486,56 +48,18 @@ async function startGeminiNexusByExecutable(execPath: string): Promise<void> {
* @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus. * @param useUri {boolean} Whether to use the URI protocol to start Gemini Nexus.
* @returns {Promise<void>} A promise that resolves when the process starts. * @returns {Promise<void>} A promise that resolves when the process starts.
*/ */
export async function startGeminiNexus(useUri = true): Promise<void> { export async function startGeminiNexus(_useUri = true): Promise<void> {
logger.info('Starting Gemini Nexus...'); logger.info('Starting Gemini Nexus proxy server...');
if (await isProcessRunning()) { if (isNestServerRunning()) {
logger.info('Gemini Nexus is already running'); logger.info('Gemini Nexus proxy server is already running');
return; return;
} }
if (useUri) { const config = ConfigManager.loadConfig();
logger.info('Using URI protocol to start...'); if (config.proxy) {
const uri = 'geminiNexus://oauth-success'; await bootstrapNestServer(config.proxy);
if (await openUri(uri)) {
logger.info('Gemini Nexus URI launch command sent');
if (process.platform !== 'linux' || isWsl()) {
return;
}
if (await waitForGeminiNexusStartup()) {
logger.info('Gemini Nexus process detected after URI launch');
return;
}
logger.warn(
'URI launch did not keep Gemini Nexus running on Linux. Falling back to executable launch.',
);
} else { } else {
logger.warn('URI launch failed, trying executable path...'); throw new Error('Proxy configuration not found');
}
}
// Fallback to executable path
logger.info('Using executable path to start...');
const execPath = getGeminiNexusExecutablePath();
try {
await startGeminiNexusByExecutable(execPath);
logger.info('Gemini Nexus launch command sent');
if (process.platform === 'linux' && !isWsl()) {
const started = await waitForGeminiNexusStartup();
if (!started) {
logger.warn(
'Gemini Nexus launch command completed, but process startup could not be confirmed on Linux.',
);
}
}
} catch (error) {
logger.error('Failed to start Gemini Nexus via executable', error);
throw error;
} }
} }
+372
View File
@@ -0,0 +1,372 @@
import { logger } from '../../utils/logger';
import { getServiceOrThrow } from './service-registry';
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
import { CloudAccountRepo } from '../database/cloudHandler';
export interface TrafficLogEntryParsed {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
headers?: Record<string, unknown>;
body?: unknown;
chunk?: unknown;
durationMs?: number;
error?: {
message: string;
stack?: string;
status?: number;
body?: unknown;
};
metadata?: Record<string, unknown>;
}
function normalizeServiceError(error: unknown): string {
if (error instanceof Error && error.message.includes('is not registered')) {
return 'Proxy advanced service not initialized';
}
return error instanceof Error ? error.message : String(error);
}
export async function getRecentTrafficLogs(
limit: number = 50,
direction?: string,
): Promise<{ success: boolean; data?: TrafficLogEntryParsed[]; error?: string }> {
try {
const rows = TrafficLogsRepo.getRecent(limit, direction);
const entries: TrafficLogEntryParsed[] = rows.map((r) => ({
timestamp: new Date(r.timestamp).toISOString(),
direction: r.direction,
requestId: r.requestId,
endpoint: r.endpoint,
method: r.method,
status: r.status,
headers: r.headers,
body: r.body,
chunk: r.chunk,
durationMs: r.durationMs,
error: r.error,
metadata: r.metadata,
}));
return { success: true, data: entries };
} catch (error) {
logger.error('Failed to get recent traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getTrafficLogStats(): Promise<{
success: boolean;
data?: { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number };
error?: string;
}> {
try {
const stats = TrafficLogsRepo.getStats();
return { success: true, data: stats };
} catch (error) {
logger.error('Failed to get traffic log stats', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function exportTrafficLogs(
format: 'json' | 'csv',
): Promise<{ success: boolean; data?: string; error?: string }> {
try {
const rows = TrafficLogsRepo.getAllForExport();
if (format === 'json') {
return { success: true, data: JSON.stringify(rows, null, 2) };
}
// CSV format
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
const rowsCsv = rows.map((e) =>
[
new Date(e.timestamp).toISOString(),
e.direction,
e.requestId,
e.endpoint,
e.method || '',
e.status != null ? e.status : '',
e.durationMs != null ? e.durationMs : '',
]
.map((v) => `"${String(v).replace(/"/g, '""')}"`)
.join(','),
);
return { success: true, data: [headers.join(','), ...rowsCsv].join('\n') };
} catch (error) {
logger.error('Failed to export traffic logs', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
export async function getParityCounters(): Promise<{
success: boolean;
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
return {
success: true,
data: tokenManager.getParityCounters(),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getCircuitBreakerStatus(): Promise<{
success: boolean;
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
error?: string;
}> {
try {
const tokenManager = getServiceOrThrow('tokenManager');
const rawStatus = tokenManager.getCircuitBreakerStatus();
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
for (const [accountId, entry] of Object.entries(rawStatus)) {
states[accountId] = {
state: entry.state,
failures: entry.failureCount,
lastFailure: entry.lastFailureTime ? new Date(entry.lastFailureTime).toISOString() : null,
};
}
return {
success: true,
data: {
states,
globalEnabled: true,
},
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getProxyMetrics(): Promise<{
success: boolean;
data?: {
uptimeSeconds: number;
totalRequests: number;
activeConnections: number;
avgLatency: number;
errorRate: number;
requestsPerMinute: number;
cacheHitRate: number;
};
error?: string;
}> {
try {
const proxyMetrics = getServiceOrThrow('proxyMetrics');
const metrics = proxyMetrics.getMetrics();
return {
success: true,
data: {
uptimeSeconds: Math.floor(process.uptime()),
totalRequests: metrics.totalRequests,
activeConnections: metrics.activeConnections,
avgLatency: metrics.avgLatency,
errorRate: metrics.errorRate,
requestsPerMinute: metrics.requestsPerMinute,
cacheHitRate: metrics.cacheHitRate,
},
};
} catch (error) {
logger.error('Failed to get proxy metrics', error);
return { success: false, error: normalizeServiceError(error) };
}
}
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
return {
success: true,
data: proxyReplay.getRecentRequests(20),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export async function replayRequest(
requestId: string,
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
const result = await proxyReplay.replayRequest(requestId);
return { success: true, data: result };
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
export interface ModelCapabilityEntry {
id: string;
object: string;
displayName?: string;
capabilities: {
vision: boolean;
thinking: boolean;
streaming: boolean;
jsonMode: boolean;
recommended: boolean;
};
limits: {
maxTokens?: number;
maxOutputTokens?: number;
thinkingBudget?: number;
};
}
export async function getModelCapabilities(): Promise<{
success: boolean;
data?: ModelCapabilityEntry[];
error?: string;
}> {
try {
const accounts = await CloudAccountRepo.getAccounts();
const modelMap = new Map<string, ModelCapabilityEntry>();
for (const account of accounts) {
if (!account.quota || !account.quota.models) {
continue;
}
for (const [modelId, modelInfo] of Object.entries(account.quota.models)) {
if (modelMap.has(modelId)) {
continue;
}
modelMap.set(modelId, {
id: modelId,
object: 'model',
displayName: modelInfo.display_name,
capabilities: {
vision: modelInfo.supports_images ?? false,
thinking: modelInfo.supports_thinking ?? false,
streaming: true,
jsonMode: true,
recommended: modelInfo.recommended ?? false,
},
limits: {
maxTokens: modelInfo.max_tokens,
maxOutputTokens: modelInfo.max_output_tokens,
thinkingBudget: modelInfo.thinking_budget,
},
});
}
}
const data = Array.from(modelMap.values()).sort((a, b) => a.id.localeCompare(b.id));
return { success: true, data };
} catch (error) {
logger.error('Failed to get model capabilities', error);
return { success: false, error: error instanceof Error ? error.message : String(error) };
}
}
function mapIdeConfigResult(
result: IdeConfigResult,
): {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
} {
switch (result.ide) {
case 'vscode':
return {
name: 'VS Code',
settingsPath: 'settings.json',
proxySetting: 'openai.url',
apiKeySetting: 'openai.apiKey',
instructions: 'Add these settings to your VS Code settings.json',
};
case 'cursor':
return {
name: 'Cursor',
settingsPath: 'settings.json',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Set these environment variables for Cursor',
};
case 'claude-code':
return {
name: 'Claude Code',
settingsPath: 'Shell environment',
proxySetting: 'ANTHROPIC_BASE_URL',
apiKeySetting: 'ANTHROPIC_API_KEY',
instructions: 'Run these export commands before starting Claude Code',
};
case 'jetbrains':
return {
name: 'JetBrains',
settingsPath: 'IDE Settings',
proxySetting: 'Base URL',
apiKeySetting: 'API Key',
instructions: String(result.content),
};
case 'opencide':
return {
name: 'Opencide',
settingsPath: '.env',
proxySetting: 'OPENAI_BASE_URL',
apiKeySetting: 'OPENAI_API_KEY',
instructions: 'Add these lines to your .env file',
};
default: {
const exhaustiveCheck: never = result.ide;
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
}
}
}
export async function generateIdeConfig(
ide: string,
): Promise<{
success: boolean;
data?: {
name: string;
settingsPath: string;
proxySetting: string;
apiKeySetting: string;
instructions: string;
};
error?: string;
}> {
try {
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
if (!supportedIdes.includes(normalizedIde)) {
return { success: false, error: `Unsupported IDE: ${ide}` };
}
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
if (!result) {
return { success: false, error: 'Failed to generate IDE config: server not configured' };
}
return {
success: true,
data: mapIdeConfigResult(result),
};
} catch (error) {
return { success: false, error: normalizeServiceError(error) };
}
}
+223
View File
@@ -0,0 +1,223 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import {
getRecentTrafficLogs,
getTrafficLogStats,
exportTrafficLogs,
getParityCounters,
getCircuitBreakerStatus,
getProxyMetrics,
getRecentRequests,
generateIdeConfig,
replayRequest,
getModelCapabilities,
} from './handler';
const TrafficLogEntrySchema = z.object({
timestamp: z.string(),
direction: z.string(),
requestId: z.string(),
endpoint: z.string(),
method: z.string().optional(),
status: z.number().optional(),
headers: z.record(z.string(), z.unknown()).optional(),
body: z.unknown().optional(),
chunk: z.unknown().optional(),
durationMs: z.number().optional(),
error: z
.object({
message: z.string(),
stack: z.string().optional(),
status: z.number().optional(),
body: z.unknown().optional(),
})
.optional(),
metadata: z.record(z.string(), z.unknown()).optional(),
});
const TrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.array(TrafficLogEntrySchema).optional(),
error: z.string().optional(),
});
const TrafficLogStatsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
byDirection: z.record(z.string(), z.number()),
byEndpoint: z.record(z.string(), z.number()),
total: z.number(),
})
.optional(),
error: z.string().optional(),
});
const ExportTrafficLogsResponseSchema = z.object({
success: z.boolean(),
data: z.string().optional(),
error: z.string().optional(),
});
const ParityCountersResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
totalRequests: z.number(),
matchedRequests: z.number(),
parityViolations: z.number(),
lastUpdated: z.string(),
})
.optional(),
error: z.string().optional(),
});
const CircuitBreakerStatusResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
states: z.record(
z.string(),
z.object({
state: z.string(),
failures: z.number(),
lastFailure: z.string().nullable(),
}),
),
globalEnabled: z.boolean(),
})
.optional(),
error: z.string().optional(),
});
const ProxyMetricsResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
uptimeSeconds: z.number(),
totalRequests: z.number(),
activeConnections: z.number(),
avgLatency: z.number(),
errorRate: z.number(),
requestsPerMinute: z.number(),
cacheHitRate: z.number(),
})
.optional(),
error: z.string().optional(),
});
const RecentRequestsResponseSchema = z.object({
success: z.boolean(),
data: z.array(z.unknown()).optional(),
error: z.string().optional(),
});
const ReplayRequestResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
original: z.unknown(),
newResponse: z.unknown(),
})
.optional(),
error: z.string().optional(),
});
const ModelCapabilitiesResponseSchema = z.object({
success: z.boolean(),
data: z
.array(
z.object({
id: z.string(),
object: z.string(),
displayName: z.string().optional(),
capabilities: z.object({
vision: z.boolean(),
thinking: z.boolean(),
streaming: z.boolean(),
jsonMode: z.boolean(),
recommended: z.boolean(),
}),
limits: z.object({
maxTokens: z.number().optional(),
maxOutputTokens: z.number().optional(),
thinkingBudget: z.number().optional(),
}),
}),
)
.optional(),
error: z.string().optional(),
});
const IdeConfigResponseSchema = z.object({
success: z.boolean(),
data: z
.object({
name: z.string(),
settingsPath: z.string(),
proxySetting: z.string(),
apiKeySetting: z.string(),
instructions: z.string(),
})
.optional(),
error: z.string().optional(),
});
export const proxyAdvancedRouter = os.router({
getRecentTrafficLogs: os
.input(
z.object({
limit: z.number().int().min(1).max(1000).optional(),
direction: z.string().optional(),
}),
)
.output(TrafficLogsResponseSchema)
.handler(async ({ input }) => {
return getRecentTrafficLogs(input.limit, input.direction);
}),
getTrafficLogStats: os.output(TrafficLogStatsResponseSchema).handler(async () => {
return getTrafficLogStats();
}),
exportTrafficLogs: os
.input(z.object({ format: z.enum(['json', 'csv']) }))
.output(ExportTrafficLogsResponseSchema)
.handler(async ({ input }) => {
return exportTrafficLogs(input.format);
}),
getParityCounters: os.output(ParityCountersResponseSchema).handler(async () => {
return getParityCounters();
}),
getCircuitBreakerStatus: os.output(CircuitBreakerStatusResponseSchema).handler(async () => {
return getCircuitBreakerStatus();
}),
getProxyMetrics: os.output(ProxyMetricsResponseSchema).handler(async () => {
return getProxyMetrics();
}),
getRecentRequests: os.output(RecentRequestsResponseSchema).handler(async () => {
return getRecentRequests();
}),
generateIdeConfig: os
.input(z.object({ ide: z.string() }))
.output(IdeConfigResponseSchema)
.handler(async ({ input }) => {
return generateIdeConfig(input.ide);
}),
replayRequest: os
.input(z.object({ requestId: z.string() }))
.output(ReplayRequestResponseSchema)
.handler(async ({ input }) => {
return replayRequest(input.requestId);
}),
getModelCapabilities: os.output(ModelCapabilitiesResponseSchema).handler(async () => {
return getModelCapabilities();
}),
});
@@ -0,0 +1,39 @@
/**
* Service registry for proxy-advanced IPC handlers to access
* NestJS service instances without DI coupling.
*
* Services auto-register on construction.
*/
import type { TokenManagerService } from '../../server/modules/proxy/token-manager.service';
import type { ProxyMetricsService } from '../../server/modules/proxy/proxy-metrics.service';
import type { ProxyReplayService } from '../../server/modules/proxy/proxy-replay.service';
import type { ProxyIdeConfigService } from '../../server/modules/proxy/proxy-ide-config.service';
interface ProxyAdvancedRegistry {
tokenManager: TokenManagerService | null;
proxyMetrics: ProxyMetricsService | null;
proxyReplay: ProxyReplayService | null;
proxyIdeConfig: ProxyIdeConfigService | null;
}
export const proxyAdvancedRegistry: ProxyAdvancedRegistry = {
tokenManager: null,
proxyMetrics: null,
proxyReplay: null,
proxyIdeConfig: null,
};
export function registerProxyAdvancedService<K extends keyof ProxyAdvancedRegistry>(
key: K,
instance: ProxyAdvancedRegistry[K],
): void {
proxyAdvancedRegistry[key] = instance;
}
export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
key: K,
): NonNullable<ProxyAdvancedRegistry[K]> {
const svc = proxyAdvancedRegistry[key];
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
return svc;
}
+4
View File
@@ -6,6 +6,8 @@ import { accountRouter } from './account/router';
import { cloudRouter } from './cloud/router'; import { cloudRouter } from './cloud/router';
import { configRouter } from './config/router'; import { configRouter } from './config/router';
import { gatewayRouter } from './gateway/router'; import { gatewayRouter } from './gateway/router';
import { usageRouter } from './usage/router';
import { proxyAdvancedRouter } from './proxy-advanced/router';
import { os } from '@orpc/server'; import { os } from '@orpc/server';
import { z } from 'zod'; import { z } from 'zod';
@@ -53,5 +55,7 @@ export const router = os.use(logMiddleware).router({
cloud: cloudRouter, cloud: cloudRouter,
config: configRouter, config: configRouter,
gateway: gatewayRouter, gateway: gatewayRouter,
usage: usageRouter,
proxyAdvanced: proxyAdvancedRouter,
system: systemHandler, system: systemHandler,
}); });
+70
View File
@@ -0,0 +1,70 @@
import { z } from 'zod';
import { os } from '@orpc/server';
import { TokenUsageRepo } from '../database/usageHandler';
import {
HourlyUsageSchema,
DailyUsageSchema,
WeeklyUsageSchema,
MonthlyUsageSchema,
ModelUsageSchema,
} from '../../types/db';
const RecordUsageInputSchema = z.object({
accountId: z.string(),
model: z.string(),
promptTokens: z.number().int().min(0),
completionTokens: z.number().int().min(0),
totalTokens: z.number().int().min(0),
timestamp: z.number().int(),
requestType: z.string().optional(),
});
const UsageQueryInputSchema = z.object({
accountId: z.string().optional(),
start: z.number().int().optional(),
end: z.number().int().optional(),
});
export const usageRouter = os.router({
recordUsage: os
.input(RecordUsageInputSchema)
.output(z.void())
.handler(async ({ input }) => {
TokenUsageRepo.recordUsage(input);
}),
getUsageByHour: os
.input(UsageQueryInputSchema)
.output(z.array(HourlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByHour(input.accountId, input.start, input.end);
}),
getUsageByDay: os
.input(UsageQueryInputSchema)
.output(z.array(DailyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByDay(input.accountId, input.start, input.end);
}),
getUsageByWeek: os
.input(UsageQueryInputSchema)
.output(z.array(WeeklyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByWeek(input.accountId, input.start, input.end);
}),
getUsageByMonth: os
.input(UsageQueryInputSchema)
.output(z.array(MonthlyUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByMonth(input.accountId, input.start, input.end);
}),
getUsageByModel: os
.input(UsageQueryInputSchema)
.output(z.array(ModelUsageSchema))
.handler(async ({ input }) => {
return TokenUsageRepo.getUsageByModel(input.accountId, input.start, input.end);
}),
});
+45 -26
View File
@@ -9,6 +9,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
RefreshCw, RefreshCw,
BarChart3,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -16,6 +17,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
import { ErrorBoundary } from 'react-error-boundary'; import { ErrorBoundary } from 'react-error-boundary';
import { useToast } from '@/components/ui/use-toast'; import { useToast } from '@/components/ui/use-toast';
import { getLocalizedErrorMessage } from '@/utils/errorMessages'; import { getLocalizedErrorMessage } from '@/utils/errorMessages';
import iconPng from '@/assets/icon.png';
export const MainLayout: React.FC = () => { export const MainLayout: React.FC = () => {
const location = useLocation(); const location = useLocation();
@@ -40,6 +42,11 @@ export const MainLayout: React.FC = () => {
icon: LayoutDashboard, icon: LayoutDashboard,
label: t('nav.accounts'), label: t('nav.accounts'),
}, },
{
to: '/usage',
icon: BarChart3,
label: t('nav.usage'),
},
{ {
to: '/proxy', to: '/proxy',
icon: Network, icon: Network,
@@ -58,14 +65,21 @@ export const MainLayout: React.FC = () => {
{/* Sidebar */} {/* Sidebar */}
<aside <aside
className={cn( className={cn(
'bg-muted/10 group relative flex flex-col border-r transition-all duration-300 ease-in-out', '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-[70px]' : 'w-64', isCollapsed ? 'w-[60px]' : 'w-56',
)} )}
> >
<Button {/* Collapse toggle */}
variant="ghost" <button
size="icon" className={cn(
className="bg-background hover:bg-accent hover:text-accent-foreground absolute top-6 -right-3 z-10 h-6 w-6 rounded-full border opacity-0 shadow-md transition-opacity group-hover:opacity-100" '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)} onClick={() => setIsCollapsed(!isCollapsed)}
> >
{isCollapsed ? ( {isCollapsed ? (
@@ -73,13 +87,14 @@ export const MainLayout: React.FC = () => {
) : ( ) : (
<ChevronLeft className="h-3 w-3" /> <ChevronLeft className="h-3 w-3" />
)} )}
</Button> </button>
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}> {/* Brand */}
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap"> <div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent"> <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 <img
src="/src/assets/icon.png" src={iconPng}
alt="Gemini Nexus Logo" alt="Gemini Nexus Logo"
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
@@ -90,12 +105,13 @@ export const MainLayout: React.FC = () => {
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100', isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
)} )}
> >
<h1 className="text-xl font-bold tracking-tight">Gemini Nexus</h1> <h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
</div> </div>
</div> </div>
</div> </div>
<nav className="flex-1 space-y-2 px-2"> {/* Navigation */}
<nav className="flex-1 space-y-1 px-2 pt-2">
<TooltipProvider> <TooltipProvider>
{navItems.map((item) => { {navItems.map((item) => {
const isActive = location.pathname === item.to; const isActive = location.pathname === item.to;
@@ -107,17 +123,19 @@ export const MainLayout: React.FC = () => {
<Link <Link
to={item.to} to={item.to}
className={cn( className={cn(
'mx-auto flex h-10 w-10 items-center justify-center rounded-md transition-colors', 'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
isActive isActive
? 'bg-primary text-primary-foreground' ? 'bg-white/[0.08] text-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
)} )}
> >
<item.icon className="h-5 w-5" /> <item.icon className="h-[18px] w-[18px]" />
<span className="sr-only">{item.label}</span> <span className="sr-only">{item.label}</span>
</Link> </Link>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="right">{item.label}</TooltipContent> <TooltipContent side="right" className="text-xs">
{item.label}
</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
@@ -127,13 +145,13 @@ export const MainLayout: React.FC = () => {
key={item.to} key={item.to}
to={item.to} to={item.to}
className={cn( className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors', 'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
isActive isActive
? 'bg-primary text-primary-foreground' ? 'bg-white/[0.08] text-foreground'
: 'hover:bg-muted text-muted-foreground hover:text-foreground', : 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
)} )}
> >
<item.icon className="h-4 w-4" /> <item.icon className="h-[18px] w-[18px]" />
{item.label} {item.label}
</Link> </Link>
); );
@@ -141,7 +159,8 @@ export const MainLayout: React.FC = () => {
</TooltipProvider> </TooltipProvider>
</nav> </nav>
<div className="border-t p-2"> {/* Status bar */}
<div className="border-t border-white/[0.06] p-2">
<StatusBar isCollapsed={isCollapsed} /> <StatusBar isCollapsed={isCollapsed} />
</div> </div>
</aside> </aside>
@@ -167,9 +186,9 @@ export const MainLayout: React.FC = () => {
}} }}
fallbackRender={({ resetErrorBoundary }) => ( fallbackRender={({ resetErrorBoundary }) => (
<div className="mx-auto max-w-3xl p-6"> <div className="mx-auto max-w-3xl p-6">
<div className="rounded-lg border border-dashed p-8 text-center"> <div className="rounded-xl border border-white/[0.06] border-dashed p-8 text-center">
<div className="text-lg font-semibold">{t('error.generic')}</div> <div className="text-sm font-semibold">{t('error.generic')}</div>
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div> <div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}> <Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t('action.retry')} {t('action.retry')}
+11 -2
View File
@@ -694,7 +694,13 @@ function buildGenerationConfig(
if (maxTokens !== undefined) { if (maxTokens !== undefined) {
config.maxOutputTokens = maxTokens; config.maxOutputTokens = maxTokens;
} }
const defaultStopSequences = ['<|user|>', '<|endoftext|>', '<|end_of_turn|>', '[DONE]', '\n\nHuman:']; const defaultStopSequences = [
'<|user|>',
'<|endoftext|>',
'<|end_of_turn|>',
'[DONE]',
'\n\nHuman:',
];
if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) { if (claudeReq.stop_sequences && claudeReq.stop_sequences.length > 0) {
config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])]; config.stopSequences = [...new Set([...claudeReq.stop_sequences, ...defaultStopSequences])];
} else { } else {
@@ -705,7 +711,10 @@ function buildGenerationConfig(
if (claudeReq.response_format) { if (claudeReq.response_format) {
if (claudeReq.response_format.type === 'json_object') { if (claudeReq.response_format.type === 'json_object') {
config.responseMimeType = 'application/json'; config.responseMimeType = 'application/json';
} else if (claudeReq.response_format.type === 'json_schema' && claudeReq.response_format.json_schema) { } else if (
claudeReq.response_format.type === 'json_schema' &&
claudeReq.response_format.json_schema
) {
config.responseMimeType = 'application/json'; config.responseMimeType = 'application/json';
config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema); config.responseSchema = cleanJsonSchema(claudeReq.response_format.json_schema);
} }
+4 -1
View File
@@ -254,7 +254,10 @@ class NonStreamingProcessor {
/** /**
* Public API: Transform Gemini Response to Claude Response * Public API: Transform Gemini Response to Claude Response
*/ */
export function transformResponse(geminiResponse: GeminiResponse, modelName?: string): ClaudeResponse { export function transformResponse(
geminiResponse: GeminiResponse,
modelName?: string,
): ClaudeResponse {
const processor = new NonStreamingProcessor(); const processor = new NonStreamingProcessor();
return processor.process(geminiResponse, modelName); return processor.process(geminiResponse, modelName);
} }
+5 -5
View File
@@ -61,8 +61,8 @@ export class QuotaService {
} else { } else {
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`); logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
} }
} catch (error: any) { } catch (error: unknown) {
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error.message}`); logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
} }
return [undefined, undefined]; return [undefined, undefined];
@@ -138,7 +138,7 @@ export class QuotaService {
} }
return { quotaData, projectId }; return { quotaData, projectId };
} catch (error: any) { } catch (error: unknown) {
let shouldFallback = true; let shouldFallback = true;
if (axios.isAxiosError(error)) { if (axios.isAxiosError(error)) {
@@ -174,7 +174,7 @@ export class QuotaService {
lastError = new Error(`HTTP ${status} - ${text}`); lastError = new Error(`HTTP ${status} - ${text}`);
shouldFallback = !isNumber(status); shouldFallback = !isNumber(status);
} else { } else {
logger.warn(`Request Failed at ${endpoint}: ${error.message}`); logger.warn(`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
lastError = error instanceof Error ? error : new Error(String(error)); lastError = error instanceof Error ? error : new Error(String(error));
} }
@@ -182,7 +182,7 @@ export class QuotaService {
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`); logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
await new Promise((r) => setTimeout(r, 1000)); await new Promise((r) => setTimeout(r, 1000));
} else { } else {
throw lastError ?? new Error(`Quota query failed: ${error.message}`); throw lastError ?? new Error(`Quota query failed: ${error instanceof Error ? error.message : String(error)}`);
} }
} }
} }
+65
View File
@@ -0,0 +1,65 @@
import type { PartialTheme } from '@nivo/theming';
/**
* Nivo chart theme aligned with Gemini Nexus design system.
* Uses Inter font family and refined dark palette.
*/
export const nivoTheme: PartialTheme = {
background: 'transparent',
text: {
fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
fontSize: 11,
fill: '#6b7280',
},
axis: {
domain: {
line: {
stroke: 'rgba(255,255,255,0.06)',
strokeWidth: 1,
},
},
ticks: {
line: {
stroke: 'transparent',
strokeWidth: 0,
},
text: {
fontFamily: "'JetBrains Mono', monospace",
fontSize: 10,
fill: '#6b7280',
},
},
legend: {
text: {
fontFamily: "Inter, sans-serif",
fontSize: 11,
fill: '#6b7280',
},
},
},
grid: {
line: {
stroke: 'rgba(255,255,255,0.04)',
strokeWidth: 1,
},
},
legends: {
text: {
fontFamily: "Inter, sans-serif",
fontSize: 11,
fill: '#6b7280',
},
},
tooltip: {
container: {
background: '#16161e',
color: '#e8eaed',
fontFamily: "Inter, sans-serif",
fontSize: 12,
borderRadius: 8,
border: '1px solid rgba(255,255,255,0.06)',
boxShadow: '0 8px 24px -4px rgba(0, 0, 0, 0.3)',
padding: '8px 12px',
},
},
};
+386
View File
@@ -58,6 +58,7 @@ i18n
}, },
nav: { nav: {
accounts: 'Accounts', accounts: 'Accounts',
usage: 'Usage',
proxy: 'API Proxy', proxy: 'API Proxy',
settings: 'Settings', settings: 'Settings',
}, },
@@ -488,10 +489,138 @@ i18n
maps_to: 'Maps to', maps_to: 'Maps to',
restore: 'Restore Defaults', restore: 'Restore Defaults',
}, },
routing: {
currentMappings: 'Current Model Mappings',
modelMapping: 'Anthropic model routing configuration',
noMapping: 'No model mappings configured.',
upstreamProxy: 'Outgoing proxy configuration',
status: 'Status',
enabled: 'Enabled',
disabled: 'Disabled',
url: 'URL',
},
examples: { examples: {
title: 'Usage Examples', title: 'Usage Examples',
description: 'Example commands to call the local API proxy.', description: 'Example commands to call the local API proxy.',
}, },
advanced: {
title: 'Advanced',
monitor: 'Monitor',
metrics: {
title: 'Performance Metrics',
requestsPerMinute: 'Requests/min',
avgLatency: 'Avg Latency',
errorRate: 'Error Rate',
activeConnections: 'Active Connections',
cacheStatus: 'Cache Hit Rate',
},
circuitBreaker: {
title: 'Circuit Breaker Status',
healthy: 'Healthy',
degraded: 'Degraded',
unhealthy: 'Unhealthy',
failureRate: 'Failure Rate',
lastFailure: 'Last Failure',
failures: 'Failures',
},
trafficLog: {
title: 'Traffic Logs',
direction: 'Filter by direction',
all: 'All directions',
inbound: 'Inbound',
outbound: 'Outbound',
exportJson: 'Export JSON',
exportCsv: 'Export CSV',
noLogs: 'No traffic logs found',
timestamp: 'Timestamp',
endpoint: 'Endpoint',
method: 'Method',
status: 'Status',
duration: 'Duration',
},
tools: {
totalRequests: 'Total Requests',
matchedRequests: 'Matched Requests',
parityViolations: 'Parity Violations',
ideSetup: 'IDE Quick Setup',
copyConfig: 'Copy Config',
requestReplay: 'Request Replay',
comingSoon: 'Coming soon',
},
trafficLogs: {
title: 'Traffic Logs',
filterDirection: 'Filter by direction',
allDirections: 'All directions',
exportJson: 'Export JSON',
exportCsv: 'Export CSV',
noLogs: 'No traffic logs found',
},
parity: {
title: 'Parity Counters',
requests: 'Parity Requests',
errors: 'Parity Errors',
shadowComparisons: 'Shadow Comparisons',
shadowMismatches: 'Shadow Mismatches',
noGoBlocked: 'No-Go Blocked',
},
ideSetup: {
title: 'IDE Quick Setup',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: 'Copy Config',
configCopied: 'Config copied!',
},
replay: {
title: 'Request Replay',
description: 'Replay recent requests for debugging',
refresh: 'Refresh',
recentRequests: 'Recent Requests',
clickToReplay: 'Click the play button to replay any request',
noRequests: 'No recent requests',
replay: 'Replay',
success: 'Replay Successful',
replayedSuccessfully: 'Request was replayed successfully',
error: 'Replay Failed',
unknownError: 'An unknown error occurred',
pending: 'Pending',
comingSoon: 'Coming soon',
},
capabilities: {
title: 'Model Capabilities',
description: 'Discover what each model can do',
},
tabs: {
tools: 'Tools',
replay: 'Replay',
capabilities: 'Capabilities',
},
},
},
usage: {
title: 'Token Usage',
description: 'Monitor your API token consumption over time.',
range24h: 'Last 24 Hours',
range7d: 'Last 7 Days',
range30d: 'Last 30 Days',
totalTokens: 'Total Tokens',
promptTokens: 'Prompt Tokens',
completionTokens: 'Completion Tokens',
totalRequests: 'Total Requests',
dailyUsage: 'Daily Usage',
byModel: 'Usage by Model',
hourlyUsage: 'Hourly Usage',
loadFailed: 'Failed to load usage data.',
retry: 'Retry',
allAccounts: 'All Accounts',
noData: 'No data available',
emptyStateTitle: 'No usage data yet',
emptyStateDescription:
'Start making API requests to see your token usage statistics here.',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: 'Total',
requestsLabel: 'Requests',
}, },
}, },
}, },
@@ -540,6 +669,7 @@ i18n
}, },
nav: { nav: {
accounts: '账号', accounts: '账号',
usage: '用量',
proxy: 'API 反代', proxy: 'API 反代',
settings: '设置', settings: '设置',
}, },
@@ -963,10 +1093,137 @@ i18n
maps_to: '映射到', maps_to: '映射到',
restore: '恢复默认', restore: '恢复默认',
}, },
routing: {
currentMappings: '当前模型映射',
modelMapping: 'Anthropic 模型路由配置',
noMapping: '未配置模型映射。',
upstreamProxy: '出站代理配置',
status: '状态',
enabled: '已启用',
disabled: '已禁用',
url: 'URL',
},
examples: { examples: {
title: '使用示例', title: '使用示例',
description: '调用本地 API 代理的示例命令。', description: '调用本地 API 代理的示例命令。',
}, },
advanced: {
title: '高级',
monitor: '监控',
metrics: {
title: '性能指标',
requestsPerMinute: '请求/分钟',
avgLatency: '平均延迟',
errorRate: '错误率',
activeConnections: '活跃连接',
cacheStatus: '缓存命中率',
},
circuitBreaker: {
title: '熔断器状态',
healthy: '健康',
degraded: '降级',
unhealthy: '不健康',
failureRate: '失败率',
lastFailure: '上次失败',
failures: '失败次数',
},
trafficLog: {
title: '流量日志',
direction: '按方向筛选',
all: '所有方向',
inbound: '入站',
outbound: '出站',
exportJson: '导出 JSON',
exportCsv: '导出 CSV',
noLogs: '暂无流量日志',
timestamp: '时间戳',
endpoint: '端点',
method: '方法',
status: '状态',
duration: '耗时',
},
tools: {
totalRequests: '总请求数',
matchedRequests: '匹配请求数',
parityViolations: '一致性违规',
ideSetup: 'IDE 快速配置',
copyConfig: '复制配置',
requestReplay: '请求重放',
comingSoon: '即将推出',
},
trafficLogs: {
title: '流量日志',
filterDirection: '按方向筛选',
allDirections: '所有方向',
exportJson: '导出 JSON',
exportCsv: '导出 CSV',
noLogs: '暂无流量日志',
},
parity: {
title: '一致性计数器',
requests: '一致性请求',
errors: '一致性错误',
shadowComparisons: '影子对比',
shadowMismatches: '影子不匹配',
noGoBlocked: '禁止通过',
},
ideSetup: {
title: 'IDE 快速配置',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: '复制配置',
configCopied: '配置已复制!',
},
replay: {
title: '请求重放',
description: '重放最近的请求以进行调试',
refresh: '刷新',
recentRequests: '最近请求',
clickToReplay: '点击播放按钮重放任何请求',
noRequests: '暂无最近请求',
replay: '重放',
success: '重放成功',
replayedSuccessfully: '请求已成功重放',
error: '重放失败',
unknownError: '发生未知错误',
pending: '待定',
comingSoon: '即将推出',
},
capabilities: {
title: '模型能力',
description: '发现每个模型可以做什么',
},
tabs: {
tools: '工具',
replay: '重放',
capabilities: '能力',
},
},
},
usage: {
title: 'Token 用量',
description: '监控您的 API Token 消耗情况。',
range24h: '最近 24 小时',
range7d: '最近 7 天',
range30d: '最近 30 天',
totalTokens: '总 Token 数',
promptTokens: 'Prompt Token 数',
completionTokens: 'Completion Token 数',
totalRequests: '总请求数',
dailyUsage: '每日用量',
byModel: '按模型统计',
hourlyUsage: '每小时用量',
loadFailed: '加载用量数据失败。',
retry: '重试',
allAccounts: '所有账号',
noData: '暂无数据',
emptyStateTitle: '暂无用量数据',
emptyStateDescription: '开始发起 API 请求后,此处将显示您的 Token 用量统计。',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: '总计',
requestsLabel: '请求数',
}, },
}, },
}, },
@@ -1012,6 +1269,7 @@ i18n
}, },
nav: { nav: {
accounts: 'Аккаунты', accounts: 'Аккаунты',
usage: 'Использование',
proxy: 'API Прокси', proxy: 'API Прокси',
settings: 'Настройки', settings: 'Настройки',
}, },
@@ -1444,10 +1702,138 @@ i18n
maps_to: '->', maps_to: '->',
restore: 'Сброс', restore: 'Сброс',
}, },
routing: {
currentMappings: 'Текущие сопоставления моделей',
modelMapping: 'Конфигурация маршрутизации моделей Anthropic',
noMapping: 'Сопоставления моделей не настроены.',
upstreamProxy: 'Конфигурация исходящего прокси',
status: 'Статус',
enabled: 'Включено',
disabled: 'Отключено',
url: 'URL',
},
examples: { examples: {
title: 'Примеры', title: 'Примеры',
description: 'Примеры использования локального прокси.', description: 'Примеры использования локального прокси.',
}, },
advanced: {
title: 'Расширенные',
monitor: 'Монитор',
metrics: {
title: 'Метрики производительности',
requestsPerMinute: 'Запросов/мин',
avgLatency: 'Средняя задержка',
errorRate: 'Частота ошибок',
activeConnections: 'Активные соединения',
cacheStatus: 'Попадание в кэш',
},
circuitBreaker: {
title: 'Состояние предохранителя',
healthy: 'Здоровый',
degraded: 'Деградированный',
unhealthy: 'Нездоровый',
failureRate: 'Частота отказов',
lastFailure: 'Последний сбой',
failures: 'Сбои',
},
trafficLog: {
title: 'Журналы трафика',
direction: 'Фильтр по направлению',
all: 'Все направления',
inbound: 'Входящий',
outbound: 'Исходящий',
exportJson: 'Экспорт JSON',
exportCsv: 'Экспорт CSV',
noLogs: 'Журналы трафика не найдены',
timestamp: 'Время',
endpoint: 'Конечная точка',
method: 'Метод',
status: 'Статус',
duration: 'Длительность',
},
tools: {
totalRequests: 'Всего запросов',
matchedRequests: 'Совпавшие запросы',
parityViolations: 'Нарушения паритета',
ideSetup: 'Быстрая настройка IDE',
copyConfig: 'Копировать конфиг',
requestReplay: 'Повтор запросов',
comingSoon: 'Скоро',
},
trafficLogs: {
title: 'Журналы трафика',
filterDirection: 'Фильтр по направлению',
allDirections: 'Все направления',
exportJson: 'Экспорт JSON',
exportCsv: 'Экспорт CSV',
noLogs: 'Журналы трафика не найдены',
},
parity: {
title: 'Счетчики паритета',
requests: 'Запросы паритета',
errors: 'Ошибки паритета',
shadowComparisons: 'Теневые сравнения',
shadowMismatches: 'Теневые несоответствия',
noGoBlocked: 'Блокировка No-Go',
},
ideSetup: {
title: 'Быстрая настройка IDE',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: 'Копировать конфиг',
configCopied: 'Конфиг скопирован!',
},
replay: {
title: 'Повтор запросов',
description: 'Повтор недавних запросов для отладки',
refresh: 'Обновить',
recentRequests: 'Недавние запросы',
clickToReplay: 'Нажмите кнопку воспроизведения, чтобы повторить любой запрос',
noRequests: 'Нет недавних запросов',
replay: 'Повторить',
success: 'Повтор успешен',
replayedSuccessfully: 'Запрос успешно повторен',
error: 'Ошибка повтора',
unknownError: 'Произошла неизвестная ошибка',
pending: 'В ожидании',
comingSoon: 'Скоро',
},
capabilities: {
title: 'Возможности моделей',
description: 'Узнайте, что может каждая модель',
},
tabs: {
tools: 'Инструменты',
replay: 'Повтор',
capabilities: 'Возможности',
},
},
},
usage: {
title: 'Использование токенов',
description: 'Мониторинг потребления API токенов.',
range24h: 'Последние 24 часа',
range7d: 'Последние 7 дней',
range30d: 'Последние 30 дней',
totalTokens: 'Всего токенов',
promptTokens: 'Prompt токены',
completionTokens: 'Completion токены',
totalRequests: 'Всего запросов',
dailyUsage: 'Ежедневное использование',
byModel: 'По моделям',
hourlyUsage: 'Почасовое использование',
loadFailed: 'Не удалось загрузить данные об использовании.',
retry: 'Повторить',
allAccounts: 'Все аккаунты',
noData: 'Нет данных',
emptyStateTitle: 'Данных об использовании пока нет',
emptyStateDescription:
'Начните отправлять API-запросы, чтобы увидеть статистику использования токенов.',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: 'Всего',
requestsLabel: 'Запросы',
}, },
}, },
}, },
+74
View File
@@ -39,6 +39,7 @@ const vi = {
}, },
nav: { nav: {
accounts: 'Tài khoản', accounts: 'Tài khoản',
usage: 'Sử dụng',
proxy: 'API Proxy', proxy: 'API Proxy',
settings: 'Cài đặt', settings: 'Cài đặt',
}, },
@@ -471,6 +472,79 @@ const vi = {
title: 'Ví dụ sử dụng', title: 'Ví dụ sử dụng',
description: 'Ví dụ lệnh để gọi local API proxy.', description: 'Ví dụ lệnh để gọi local API proxy.',
}, },
advanced: {
title: 'Nâng cao',
monitor: 'Giám sát',
routing: 'Định tuyến',
tools: 'Công cụ',
metrics: {
title: 'Chỉ số hiệu suất',
requestsPerMinute: 'Yêu cầu/phút',
avgLatency: 'Độ trễ TB',
errorRate: 'Tỷ lệ lỗi',
activeConnections: 'Kết nối hoạt động',
},
circuitBreaker: {
title: 'Trạng thái Circuit Breaker',
healthy: 'Khỏe',
degraded: 'Suy giảm',
unhealthy: 'Không khỏe',
failureRate: 'Tỷ lệ thất bại',
lastFailure: 'Lỗi gần nhất',
},
trafficLogs: {
title: 'Nhật ký lưu lượng',
filterDirection: 'Lọc theo hướng',
allDirections: 'Tất cả hướng',
exportJson: 'Xuất JSON',
exportCsv: 'Xuất CSV',
noLogs: 'Không tìm thấy nhật ký',
},
parity: {
title: 'Bộ đếm Parity',
requests: 'Yêu cầu Parity',
errors: 'Lỗi Parity',
shadowComparisons: 'So sánh Shadow',
shadowMismatches: 'Không khớp Shadow',
noGoBlocked: 'No-Go đã chặn',
},
ideSetup: {
title: 'Thiết lập IDE nhanh',
cursor: 'Cursor',
vscode: 'VS Code',
claudeCode: 'Claude Code',
copyConfig: 'Sao chép cấu hình',
configCopied: 'Đã sao chép cấu hình!',
},
replay: {
title: 'Phát lại yêu cầu',
comingSoon: 'Sắp ra mắt',
},
},
},
usage: {
title: 'Token Usage',
description: 'Theo dõi mức tiêu thụ API token của bạn.',
range24h: '24 giờ qua',
range7d: '7 ngày qua',
range30d: '30 ngày qua',
totalTokens: 'Tổng Token',
promptTokens: 'Prompt Token',
completionTokens: 'Completion Token',
totalRequests: 'Tổng Request',
dailyUsage: 'Sử dụng hàng ngày',
byModel: 'Theo Model',
hourlyUsage: 'Sử dụng theo giờ',
loadFailed: 'Không thể tải dữ liệu sử dụng.',
retry: 'Thử lại',
allAccounts: 'Tất cả tài khoản',
noData: 'Không có dữ liệu',
emptyStateTitle: 'Chưa có dữ liệu sử dụng',
emptyStateDescription: 'Bắt đầu gửi yêu cầu API để xem thống kê sử dụng token tại đây.',
promptLabel: 'Prompt',
completionLabel: 'Completion',
totalLabel: 'Tổng',
requestsLabel: 'Yêu cầu',
}, },
}; };
+1 -1
View File
@@ -235,7 +235,7 @@ function createWindow({ startHidden }: { startHidden: boolean }) {
// Use process.cwd() in dev to find the icon reliably // Use process.cwd() in dev to find the icon reliably
icon: inDevelopment icon: inDevelopment
? path.join(process.cwd(), 'src/assets/icon.png') ? path.join(process.cwd(), 'src/assets/icon.png')
: path.join(__dirname, '../assets/icon.png'), : path.join(process.resourcesPath, 'assets', 'icon.png'),
}); });
globalMainWindow = mainWindow; globalMainWindow = mainWindow;
logger.info('createWindow: BrowserWindow instance created'); logger.info('createWindow: BrowserWindow instance created');
+21 -3
View File
@@ -9,10 +9,16 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root' import { Route as rootRouteImport } from './routes/__root'
import { Route as UsageRouteImport } from './routes/usage'
import { Route as SettingsRouteImport } from './routes/settings' import { Route as SettingsRouteImport } from './routes/settings'
import { Route as ProxyRouteImport } from './routes/proxy' import { Route as ProxyRouteImport } from './routes/proxy'
import { Route as IndexRouteImport } from './routes/index' import { Route as IndexRouteImport } from './routes/index'
const UsageRoute = UsageRouteImport.update({
id: '/usage',
path: '/usage',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsRoute = SettingsRouteImport.update({ const SettingsRoute = SettingsRouteImport.update({
id: '/settings', id: '/settings',
path: '/settings', path: '/settings',
@@ -33,34 +39,45 @@ export interface FileRoutesByFullPath {
'/': typeof IndexRoute '/': typeof IndexRoute
'/proxy': typeof ProxyRoute '/proxy': typeof ProxyRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/usage': typeof UsageRoute
} }
export interface FileRoutesByTo { export interface FileRoutesByTo {
'/': typeof IndexRoute '/': typeof IndexRoute
'/proxy': typeof ProxyRoute '/proxy': typeof ProxyRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/usage': typeof UsageRoute
} }
export interface FileRoutesById { export interface FileRoutesById {
__root__: typeof rootRouteImport __root__: typeof rootRouteImport
'/': typeof IndexRoute '/': typeof IndexRoute
'/proxy': typeof ProxyRoute '/proxy': typeof ProxyRoute
'/settings': typeof SettingsRoute '/settings': typeof SettingsRoute
'/usage': typeof UsageRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/proxy' | '/settings' fullPaths: '/' | '/proxy' | '/settings' | '/usage'
fileRoutesByTo: FileRoutesByTo fileRoutesByTo: FileRoutesByTo
to: '/' | '/proxy' | '/settings' to: '/' | '/proxy' | '/settings' | '/usage'
id: '__root__' | '/' | '/proxy' | '/settings' id: '__root__' | '/' | '/proxy' | '/settings' | '/usage'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
export interface RootRouteChildren { export interface RootRouteChildren {
IndexRoute: typeof IndexRoute IndexRoute: typeof IndexRoute
ProxyRoute: typeof ProxyRoute ProxyRoute: typeof ProxyRoute
SettingsRoute: typeof SettingsRoute SettingsRoute: typeof SettingsRoute
UsageRoute: typeof UsageRoute
} }
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {
interface FileRoutesByPath { interface FileRoutesByPath {
'/usage': {
id: '/usage'
path: '/usage'
fullPath: '/usage'
preLoaderRoute: typeof UsageRouteImport
parentRoute: typeof rootRouteImport
}
'/settings': { '/settings': {
id: '/settings' id: '/settings'
path: '/settings' path: '/settings'
@@ -89,6 +106,7 @@ const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute, IndexRoute: IndexRoute,
ProxyRoute: ProxyRoute, ProxyRoute: ProxyRoute,
SettingsRoute: SettingsRoute, SettingsRoute: SettingsRoute,
UsageRoute: UsageRoute,
} }
export const routeTree = rootRouteImport export const routeTree = rootRouteImport
._addFileChildren(rootRouteChildren) ._addFileChildren(rootRouteChildren)
+3 -3
View File
@@ -35,9 +35,9 @@ function HomePageErrorBoundary({ error, reset }: { error: unknown; reset: () =>
return ( return (
<div className="container mx-auto max-w-4xl p-6"> <div className="container mx-auto max-w-4xl p-6">
<div className="rounded-lg border border-dashed p-8 text-center"> <div className="rounded-xl border border-dashed border-white/[0.1] p-8 text-center">
<div className="text-lg font-semibold">{t('error.generic')}</div> <div className="text-base font-semibold">{t('error.generic')}</div>
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div> <div className="text-muted-foreground mt-2 text-[13px]">{t('action.retry')}</div>
<Button className="mt-4" variant="outline" onClick={() => reset()}> <Button className="mt-4" variant="outline" onClick={() => reset()}>
<RefreshCw className="mr-2 h-4 w-4" /> <RefreshCw className="mr-2 h-4 w-4" />
{t('action.retry')} {t('action.retry')}
+360 -152
View File
@@ -2,11 +2,11 @@
* API Proxy Service Page * API Proxy Service Page
* Provides service control, model mapping, and usage examples * Provides service control, model mapping, and usage examples
*/ */
import { createFileRoute } from '@tanstack/react-router'; import { createFileRoute, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager'; import { ipc } from '@/ipc/manager';
import { useEffect, useState, useMemo, type ReactNode } from 'react'; import { useEffect, useState, useMemo } from 'react';
import { useAppConfig } from '@/hooks/useAppConfig'; import { useAppConfig } from '@/hooks/useAppConfig';
import { ProxyConfig } from '@/types/config'; import { ProxyConfig } from '@/types/config';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
@@ -14,17 +14,19 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { useToast } from '@/components/ui/use-toast';
import { import {
Select, Select,
SelectContent, SelectContent,
SelectGroup, SelectGroup,
SelectItem, SelectItem,
SelectLabel, SelectLabel,
SelectSeparator,
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from '@/components/ui/select'; } from '@/components/ui/select';
import { Loader2, Copy, CheckCircle, Code, Terminal, Eye, EyeOff } from 'lucide-react'; import { Loader2, Copy, Code, Terminal, Eye, EyeOff, ServerOff, ChevronDown, ChevronUp } from 'lucide-react';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -35,6 +37,7 @@ import {
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useCloudAccounts } from '@/hooks/useCloudAccounts'; import { useCloudAccounts } from '@/hooks/useCloudAccounts';
import { ProxyAdvancedTab } from '@/components/proxy/ProxyAdvancedTab';
import { flatMap, sortBy, uniq } from 'lodash-es'; import { flatMap, sortBy, uniq } from 'lodash-es';
type ProxyProtocol = 'openai' | 'anthropic'; type ProxyProtocol = 'openai' | 'anthropic';
@@ -78,6 +81,7 @@ function resolveAnthropicMappingValue(
function ProxyPage() { function ProxyPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { toast } = useToast();
const { config, isLoading, saveConfig } = useAppConfig(); const { config, isLoading, saveConfig } = useAppConfig();
// Query all available local IPs // Query all available local IPs
@@ -103,7 +107,7 @@ function ProxyPage() {
useEffect(() => { useEffect(() => {
if (localIps && localIps.length > 0 && !selectedIp) { if (localIps && localIps.length > 0 && !selectedIp) {
const recommended = localIps.find((ip) => ip.isRecommended); const recommended = localIps.find((ip) => ip.isRecommended);
setSelectedIp(recommended?.address || localIps[0].address); setTimeout(() => setSelectedIp(recommended?.address || localIps[0].address), 0);
} }
}, [localIps, selectedIp]); }, [localIps, selectedIp]);
@@ -151,7 +155,15 @@ function ProxyPage() {
const { data: accounts } = useCloudAccounts(); const { data: accounts } = useCloudAccounts();
const [selectedProtocol, setSelectedProtocol] = useState<ProxyProtocol>('openai'); const [selectedProtocol, setSelectedProtocol] = useState<ProxyProtocol>('openai');
const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high'); const [activeModelTab, setActiveModelTab] = useState('gemini-3.1-pro-high');
const [copied, setCopied] = useState<string | null>(null); const [showCurl, setShowCurl] = useState(true);
const [showPython, setShowPython] = useState(true);
const { data: metricsData } = useQuery({
queryKey: ['proxyAdvanced', 'metrics'],
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
refetchInterval: 30000,
enabled: proxyConfig?.enabled ?? false,
});
const availableModelIds: string[] = useMemo(() => { const availableModelIds: string[] = useMemo(() => {
if (!accounts || accounts.length === 0) { if (!accounts || accounts.length === 0) {
@@ -196,7 +208,7 @@ function ProxyPage() {
useEffect(() => { useEffect(() => {
if (!availableModelIds.includes(activeModelTab) && availableModelIds.length > 0) { if (!availableModelIds.includes(activeModelTab) && availableModelIds.length > 0) {
setActiveModelTab(availableModelIds[0]); setTimeout(() => setActiveModelTab(availableModelIds[0]), 0);
} }
}, [availableModelIds, activeModelTab]); }, [availableModelIds, activeModelTab]);
@@ -217,10 +229,9 @@ function ProxyPage() {
}); });
}; };
const copyToClipboard = (text: string, type: string) => { const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text); navigator.clipboard.writeText(text);
setCopied(type); toast({ title: t('proxy.copied') });
setTimeout(() => setCopied(null), 2000);
}; };
const getCurlExample = (modelId: string) => { const getCurlExample = (modelId: string) => {
@@ -283,46 +294,24 @@ print(response.choices[0].message.content)`;
} }
return ( return (
<div className="container mx-auto max-w-4xl space-y-5 p-6"> <div className="container mx-auto max-w-4xl space-y-5 overflow-auto p-6">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title')}</h2> <h2 className="text-lg font-semibold tracking-tight">{t('proxy.title')}</h2>
<p className="text-muted-foreground mt-1">{t('proxy.description')}</p> <p className="text-muted-foreground mt-1 text-[13px]">{t('proxy.description')}</p>
{/* Local Access Info Banner */}
{proxyConfig?.enabled && (
<div className="mt-4 flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
<div className="flex items-center gap-2">
<div className="font-semibold">{t('proxy.config.local_access')}</div>
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
</code>
{/* IP Selector Dropdown */}
{localIps && localIps.length > 1 && (
<Select value={selectedIp} onValueChange={setSelectedIp}>
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
<SelectValue placeholder={t('proxy.config.select_ip')} />
</SelectTrigger>
<SelectContent>
{localIps.map((ip) => (
<SelectItem key={ip.address} value={ip.address} className="text-xs">
{ip.address} ({ip.name}){ip.isRecommended && ' ★'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{!proxyConfig.api_key && (
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
{t('proxy.config.no_token_warning')}
</div>
)}
</div>
)}
</div> </div>
<Tabs defaultValue="status">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="status">Status</TabsTrigger>
<TabsTrigger value="routing">Routing</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 */} {/* Service Control Card */}
<Card> <Card className="border border-white/[0.06] bg-card shadow-none">
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -331,15 +320,15 @@ print(response.choices[0].message.content)`;
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div <div
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`} className={`h-2.5 w-2.5 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-emerald-400' : 'bg-muted-foreground/30'}`}
></div> ></div>
<span className="text-sm font-medium"> <span className="text-[13px] font-medium">
{proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')} {proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
</span> </span>
</div> </div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-5">
{/* Start/Stop Button */} {/* Start/Stop Button */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Button <Button
@@ -398,7 +387,7 @@ print(response.choices[0].message.content)`;
value={proxyConfig.api_key || ''} value={proxyConfig.api_key || ''}
readOnly readOnly
type={showKey ? 'text' : 'password'} type={showKey ? 'text' : 'password'}
className="pr-10 font-mono text-sm" className="pr-10 font-mono text-[13px]"
/> />
<Button <Button
variant="ghost" variant="ghost"
@@ -417,7 +406,7 @@ print(response.choices[0].message.content)`;
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => navigator.clipboard.writeText(proxyConfig.api_key || '')} onClick={() => copyToClipboard(proxyConfig.api_key || '')}
> >
<Copy size={14} className="mr-1" /> <Copy size={14} className="mr-1" />
{t('proxy.copy')} {t('proxy.copy')}
@@ -426,6 +415,18 @@ print(response.choices[0].message.content)`;
{t('proxy.regenerate')} {t('proxy.regenerate')}
</Button> </Button>
</div> </div>
{(!proxyConfig.api_key || proxyConfig.api_key.length < 10) && (
<div className="flex items-center gap-2">
<Badge variant="destructive">
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
</Badge>
<span className="text-xs text-muted-foreground">
{proxyConfig.api_key
? 'API key is too short for secure operation'
: 'Generate or set an API key to secure the proxy'}
</span>
</div>
)}
<Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}> <Dialog open={isRegenerateDialogOpen} onOpenChange={setIsRegenerateDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -453,10 +454,10 @@ print(response.choices[0].message.content)`;
</div> </div>
{/* Auto Start Toggle */} {/* Auto Start Toggle */}
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('proxy.config.auto_start')}</Label> <Label>{t('proxy.config.auto_start')}</Label>
<p className="text-xs text-gray-500">{t('proxy.config.auto_start_desc')}</p> <p className="text-xs text-muted-foreground">{t('proxy.config.auto_start_desc')}</p>
</div> </div>
<Switch <Switch
checked={proxyConfig.auto_start} checked={proxyConfig.auto_start}
@@ -468,25 +469,160 @@ print(response.choices[0].message.content)`;
</CardContent> </CardContent>
</Card> </Card>
{/* Model Mapping Card */} {/* Account Quota Context */}
<Card> <Card className="border border-white/[0.06] bg-card shadow-none">
<CardHeader>
<CardTitle>Account Pool</CardTitle>
<CardDescription>Quota and routing context</CardDescription>
</CardHeader>
<CardContent>
{accounts && accounts.length > 0 ? (
<div className="space-y-3">
<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>
</div>
{accounts.length > 1 && (
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
)}
</div>
<Button variant="outline" size="sm" className="text-xs" asChild>
<Link to={'/accounts' as any}>Manage Accounts</Link>
</Button>
</div>
) : (
<div className="flex flex-col items-center gap-3 py-6 text-center">
<p className="text-muted-foreground text-[13px]">No accounts configured</p>
<Button size="sm" className="text-xs" asChild>
<Link to={'/accounts' as any}>Add Account</Link>
</Button>
</div>
)}
</CardContent>
</Card>
{proxyConfig.enabled ? (
<>
{/* 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="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
</code>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
onClick={() =>
copyToClipboard(
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
)
}
>
<Copy size={14} />
</Button>
{localIps && localIps.length > 1 && (
<Select value={selectedIp} onValueChange={setSelectedIp}>
<SelectTrigger className="ml-2 h-7 w-auto min-w-[180px] text-xs">
<SelectValue placeholder={t('proxy.config.select_ip')} />
</SelectTrigger>
<SelectContent>
{localIps.map((ip) => (
<SelectItem key={ip.address} value={ip.address} className="text-xs">
{ip.address} ({ip.name}){ip.isRecommended && ' ★'}
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{!proxyConfig.api_key && (
<div className="flex items-center gap-2 text-xs font-medium text-amber-400">
{t('proxy.config.no_token_warning')}
</div>
)}
</div>
{/* Health Metrics */}
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
{(() => {
const metrics = metricsData?.success ? metricsData.data : undefined;
const items = [
{
label: t('proxy.advanced.metrics.requestsPerMinute'),
value: metrics?.requestsPerMinute ?? 0,
suffix: '',
},
{
label: t('proxy.advanced.metrics.avgLatency'),
value: metrics?.avgLatency ?? 0,
suffix: 'ms',
},
{
label: t('proxy.advanced.metrics.errorRate'),
value: metrics?.errorRate ?? 0,
suffix: '%',
},
{
label: t('proxy.advanced.metrics.activeConnections'),
value: metrics?.activeConnections ?? 0,
suffix: '',
},
];
return items.map((metric) => (
<Card key={metric.label} className="border border-white/[0.06] bg-card shadow-none">
<CardContent className="pt-6">
<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}
{metric.suffix}
</span>
</div>
</CardContent>
</Card>
));
})()}
</div>
</>
) : (
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-white/[0.1] p-12 text-center">
<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>
</div>
<Button
onClick={async () => {
const { ipc } = await import('@/ipc/manager');
await ipc.client.gateway.start({ port: proxyConfig.port });
updateProxyConfig({ ...proxyConfig, enabled: true });
}}
>
Start Proxy
</Button>
</div>
)}
</TabsContent>
{/* Routing Tab */}
<TabsContent value="routing" className="space-y-5">
<Card className="border border-white/[0.06] bg-card shadow-none">
<CardHeader> <CardHeader>
<CardTitle>{t('proxy.mapping.title')}</CardTitle> <CardTitle>{t('proxy.mapping.title')}</CardTitle>
<CardDescription>{t('proxy.mapping.description')}</CardDescription> <CardDescription>{t('proxy.mapping.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-4">
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="overflow-hidden rounded-xl border border-white/[0.06]">
{/* Sonnet 4.6 Card */} <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="flex flex-col rounded-lg border-2 border-blue-200 bg-gradient-to-br from-blue-50 to-blue-100/50 p-4 dark:border-blue-800/50 dark:from-blue-950/30 dark:to-blue-900/20"> <div>Requested Model</div>
<div className="mb-2 flex items-center gap-2"> <div>Resolved Model</div>
<div className="h-2 w-2 animate-pulse rounded-full bg-blue-500"></div> <div>Provider</div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
Claude Sonnet 4.6 (Thinking)
</h3>
</div> </div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400"> <div className="grid grid-cols-3 gap-4 items-center p-3 text-[13px]">
{t('proxy.mapping.maps_to')} <div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
</p>
<Select <Select
value={resolveAnthropicMappingValue( value={resolveAnthropicMappingValue(
proxyConfig.anthropic_mapping, proxyConfig.anthropic_mapping,
@@ -511,19 +647,10 @@ print(response.choices[0].message.content)`;
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Badge variant="secondary">Anthropic</Badge>
</div> </div>
<div className="grid grid-cols-3 gap-4 items-center border-t border-white/[0.06] p-3 text-[13px]">
{/* Opus 4.6 Card */} <div className="font-medium">Claude Opus 4.6 (Thinking)</div>
<div className="flex flex-col rounded-lg border-2 border-purple-200 bg-gradient-to-br from-purple-50 to-purple-100/50 p-4 dark:border-purple-800/50 dark:from-purple-950/30 dark:to-purple-900/20">
<div className="mb-2 flex items-center gap-2">
<div className="h-2 w-2 animate-pulse rounded-full bg-purple-500"></div>
<h3 className="text-sm font-bold text-gray-900 dark:text-gray-100">
Claude Opus 4.6 (Thinking)
</h3>
</div>
<p className="mb-3 text-xs text-gray-600 dark:text-gray-400">
{t('proxy.mapping.maps_to')}
</p>
<Select <Select
value={resolveAnthropicMappingValue( value={resolveAnthropicMappingValue(
proxyConfig.anthropic_mapping, proxyConfig.anthropic_mapping,
@@ -548,9 +675,9 @@ print(response.choices[0].message.content)`;
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<Badge variant="secondary">Anthropic</Badge>
</div> </div>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button <Button
variant="outline" variant="outline"
@@ -568,8 +695,77 @@ print(response.choices[0].message.content)`;
</CardContent> </CardContent>
</Card> </Card>
{/* Usage Examples Card */} {/* Current Mappings */}
<Card> <Card className="border border-white/[0.06] bg-card shadow-none">
<CardHeader>
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
</CardHeader>
<CardContent>
{(() => {
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>
) : (
<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>{t('proxy.mapping.maps_to')}</div>
<div>{t('settings.modelMapping.targetGemini')}</div>
<div>Provider</div>
</div>
{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"
>
<div className="font-mono text-xs">{key}</div>
<div className="font-mono text-xs">{value}</div>
<Badge variant="secondary">Anthropic</Badge>
</div>
))}
{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"
>
<div className="font-mono text-xs">{key}</div>
<div className="font-mono text-xs">{value}</div>
<Badge variant="outline">Custom</Badge>
</div>
))}
</div>
);
})()}
</CardContent>
</Card>
{/* Upstream Proxy Status */}
<Card className="border border-white/[0.06] bg-card 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>
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
{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>
</div>
)}
</CardContent>
</Card>
</TabsContent>
{/* API Docs Tab */}
<TabsContent value="api-docs" className="space-y-5">
<Card className="border border-white/[0.06] bg-card shadow-none">
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2"> <CardTitle className="flex items-center gap-2">
<Code size={20} /> <Code size={20} />
@@ -578,59 +774,26 @@ print(response.choices[0].message.content)`;
<CardDescription>{t('proxy.examples.description')}</CardDescription> <CardDescription>{t('proxy.examples.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
{/* Protocol Selector Cards */} {/* Compact Protocol Selector */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="flex items-center gap-2">
{/* OpenAI Protocol Card */} <Button
<div variant={selectedProtocol === 'openai' ? 'default' : 'outline'}
className={`cursor-pointer rounded-lg border-2 bg-gradient-to-br from-blue-50 to-blue-100/50 p-4 transition-all dark:from-blue-950/30 dark:to-blue-900/20 ${selectedProtocol === 'openai' ? 'border-blue-500 shadow-md dark:border-blue-600' : 'border-blue-200 hover:border-blue-300 dark:border-blue-800/50'}`}
onClick={() => setSelectedProtocol('openai')} onClick={() => setSelectedProtocol('openai')}
> >
<div className="mb-3 flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${selectedProtocol === 'openai' ? 'animate-pulse bg-blue-500' : 'bg-blue-400'}`}
></div>
<span className="text-sm font-bold text-blue-700 dark:text-blue-400">
{t('settings.examples.openai_protocol')} {t('settings.examples.openai_protocol')}
</span> </Button>
</div> <Button
<div className="mb-2 rounded border border-blue-200/50 bg-white/60 px-3 py-2 dark:border-blue-700/30 dark:bg-gray-800/40"> variant={selectedProtocol === 'anthropic' ? 'default' : 'outline'}
<code className="font-mono text-xs break-all text-gray-800 dark:text-gray-200">
POST /v1/chat/completions
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('settings.examples.openai_tools')}
</p>
</div>
{/* Anthropic Protocol Card */}
<div
className={`cursor-pointer rounded-lg border-2 bg-gradient-to-br from-purple-50 to-purple-100/50 p-4 transition-all dark:from-purple-950/30 dark:to-purple-900/20 ${selectedProtocol === 'anthropic' ? 'border-purple-500 shadow-md dark:border-purple-600' : 'border-purple-200 hover:border-purple-300 dark:border-purple-800/50'}`}
onClick={() => setSelectedProtocol('anthropic')} onClick={() => setSelectedProtocol('anthropic')}
> >
<div className="mb-3 flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${selectedProtocol === 'anthropic' ? 'animate-pulse bg-purple-500' : 'bg-purple-400'}`}
></div>
<span className="text-sm font-bold text-purple-700 dark:text-purple-400">
{t('settings.examples.anthropic_protocol')} {t('settings.examples.anthropic_protocol')}
</span> </Button>
</div>
<div className="mb-2 rounded border border-purple-200/50 bg-white/60 px-3 py-2 dark:border-purple-700/30 dark:bg-gray-800/40">
<code className="font-mono text-xs break-all text-gray-800 dark:text-gray-200">
POST /v1/messages
</code>
</div>
<p className="text-xs text-gray-600 dark:text-gray-400">
{t('settings.examples.anthropic_tools')}
</p>
</div>
</div> </div>
{/* Model Selector & Copy API ID */} {/* Model Selector & Copy API ID */}
<div className="flex flex-col gap-4 border-b border-gray-200 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700"> <div className="flex flex-col gap-4 border-b border-white/[0.06] pb-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Label className="text-sm font-medium"> <Label className="text-[13px] font-medium">
Model{' '} Model{' '}
<span className="text-muted-foreground ml-1 font-normal"> <span className="text-muted-foreground ml-1 font-normal">
({availableModelIds.length} available) ({availableModelIds.length} available)
@@ -662,59 +825,104 @@ print(response.choices[0].message.content)`;
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => copyToClipboard(activeModelTab, 'model-id')} onClick={() => copyToClipboard(activeModelTab)}
className="h-9 text-xs" className="h-9 text-xs"
> >
{copied === 'model-id' ? (
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
) : (
<Copy className="mr-2 h-4 w-4" /> <Copy className="mr-2 h-4 w-4" />
)}
Copy API Name Copy API Name
</Button> </Button>
</div> </div>
{/* cURL Example */} {/* Collapsible Code Examples */}
<div> <div className="space-y-3">
<div className="mb-2 flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100"> <Label className="text-[13px] font-medium">Code Examples</Label>
<Button
variant="ghost"
size="sm"
onClick={() => {
const allOpen = showCurl && showPython;
setShowCurl(!allOpen);
setShowPython(!allOpen);
}}
>
{showCurl && showPython ? 'Collapse All' : 'Expand All'}
</Button>
</div>
{/* 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"
onClick={() => setShowCurl(!showCurl)}
>
<span className="flex items-center gap-2">
<Terminal size={16} /> <Terminal size={16} />
cURL cURL
</span> </span>
<button <div className="flex items-center gap-2">
onClick={() => copyToClipboard(getCurlExample(activeModelTab), 'curl')} <Button
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700" variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(getCurlExample(activeModelTab));
}}
> >
{copied === 'curl' ? <CheckCircle size={14} /> : <Copy size={14} />} <Copy size={14} />
{copied === 'curl' ? t('proxy.copied') : t('proxy.copy')} </Button>
</button> {showCurl ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div> </div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100"> </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">
{getCurlExample(activeModelTab)} {getCurlExample(activeModelTab)}
</pre> </pre>
)}
</div> </div>
{/* Python Example */} {/* Python */}
<div> <div className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="mb-2 flex items-center justify-between"> <div
<span className="flex items-center gap-2 text-sm font-medium text-gray-900 dark:text-gray-100"> className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
onClick={() => setShowPython(!showPython)}
>
<span className="flex items-center gap-2">
<Code size={16} /> <Code size={16} />
Python Python
</span> </span>
<button <div className="flex items-center gap-2">
onClick={() => copyToClipboard(getPythonExample(activeModelTab), 'python')} <Button
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-700" variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => {
e.stopPropagation();
copyToClipboard(getPythonExample(activeModelTab));
}}
> >
{copied === 'python' ? <CheckCircle size={14} /> : <Copy size={14} />} <Copy size={14} />
{copied === 'python' ? t('proxy.copied') : t('proxy.copy')} </Button>
</button> {showPython ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
</div> </div>
<pre className="overflow-x-auto rounded-lg bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100"> </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">
{getPythonExample(activeModelTab)} {getPythonExample(activeModelTab)}
</pre> </pre>
)}
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
</TabsContent>
{/* Advanced Tab */}
<TabsContent value="advanced">
<ProxyAdvancedTab proxyConfig={proxyConfig} />
</TabsContent>
</Tabs>
</div> </div>
); );
} }
+27 -27
View File
@@ -79,8 +79,8 @@ function SettingsPage() {
return ( return (
<div className="container mx-auto max-w-4xl space-y-5 p-6"> <div className="container mx-auto max-w-4xl space-y-5 p-6">
<div> <div>
<h2 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h2> <h2 className="text-lg font-semibold tracking-tight">{t('settings.title')}</h2>
<p className="text-muted-foreground mt-1">{t('settings.description')}</p> <p className="text-muted-foreground mt-1 text-[13px]">{t('settings.description')}</p>
</div> </div>
<Tabs defaultValue="general" className="w-full"> <Tabs defaultValue="general" className="w-full">
@@ -92,16 +92,16 @@ function SettingsPage() {
{/* --- GENERAL TAB --- */} {/* --- GENERAL TAB --- */}
<TabsContent value="general" className="space-y-5"> <TabsContent value="general" className="space-y-5">
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.appearance.title')}</CardTitle> <CardTitle>{t('settings.appearance.title')}</CardTitle>
<CardDescription>{t('settings.appearance.description')}</CardDescription> <CardDescription>{t('settings.appearance.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-5">
<div className="flex items-center justify-between space-x-2"> <div className="flex items-center justify-between space-x-2">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="dark-mode">{t('settings.darkMode')}</Label> <Label htmlFor="dark-mode">{t('settings.darkMode')}</Label>
<p className="text-muted-foreground text-sm"> <p className="text-muted-foreground text-xs">
{t('settings.darkModeDescription')} {t('settings.darkModeDescription')}
</p> </p>
</div> </div>
@@ -139,8 +139,8 @@ function SettingsPage() {
</Card> </Card>
{/* Account Settings Card */} {/* Account Settings Card */}
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.account.title')}</CardTitle> <CardTitle>{t('settings.account.title')}</CardTitle>
<CardDescription>{t('settings.account.description')}</CardDescription> <CardDescription>{t('settings.account.description')}</CardDescription>
</CardHeader> </CardHeader>
@@ -149,7 +149,7 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.account.auto_refresh')}</Label> <Label>{t('settings.account.auto_refresh')}</Label>
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p> <p className="text-muted-foreground text-xs">{t('settings.account.auto_refresh_desc')}</p>
</div> </div>
<Switch <Switch
checked={config?.auto_refresh || false} checked={config?.auto_refresh || false}
@@ -165,7 +165,7 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.account.auto_sync')}</Label> <Label>{t('settings.account.auto_sync')}</Label>
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p> <p className="text-muted-foreground text-xs">{t('settings.account.auto_sync_desc')}</p>
</div> </div>
<Switch <Switch
checked={config?.auto_sync || false} checked={config?.auto_sync || false}
@@ -180,8 +180,8 @@ function SettingsPage() {
</Card> </Card>
{isAutoStartSupported && ( {isAutoStartSupported && (
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.startup.title')}</CardTitle> <CardTitle>{t('settings.startup.title')}</CardTitle>
<CardDescription>{t('settings.startup.description')}</CardDescription> <CardDescription>{t('settings.startup.description')}</CardDescription>
</CardHeader> </CardHeader>
@@ -189,7 +189,7 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.startup.auto_startup')}</Label> <Label>{t('settings.startup.auto_startup')}</Label>
<p className="text-xs text-gray-500"> <p className="text-muted-foreground text-xs">
{t('settings.startup.auto_startup_desc')} {t('settings.startup.auto_startup_desc')}
</p> </p>
</div> </div>
@@ -211,13 +211,13 @@ function SettingsPage() {
</Card> </Card>
)} )}
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.about.title')}</CardTitle> <CardTitle>{t('settings.about.title')}</CardTitle>
<CardDescription>{t('settings.about.description')}</CardDescription> <CardDescription>{t('settings.about.description')}</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-[13px]">
<div className="text-muted-foreground">{t('settings.version')}</div> <div className="text-muted-foreground">{t('settings.version')}</div>
<div className="font-medium">{appVersion || t('common.unknown')}</div> <div className="font-medium">{appVersion || t('common.unknown')}</div>
@@ -230,7 +230,7 @@ function SettingsPage() {
<div className="text-muted-foreground">{t('action.openLogs')}</div> <div className="text-muted-foreground">{t('action.openLogs')}</div>
<button <button
onClick={() => openLogDirectory()} onClick={() => openLogDirectory()}
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300" className="flex items-center gap-2 font-medium text-accent hover:text-accent/80 transition-colors"
> >
<FolderOpen className="h-4 w-4" /> <FolderOpen className="h-4 w-4" />
<span>{t('settings.openLogDir')}</span> <span>{t('settings.openLogDir')}</span>
@@ -240,8 +240,8 @@ function SettingsPage() {
</Card> </Card>
{/* Privacy & Error Reporting Card */} {/* Privacy & Error Reporting Card */}
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.privacy.title')}</CardTitle> <CardTitle>{t('settings.privacy.title')}</CardTitle>
<CardDescription>{t('settings.privacy.description')}</CardDescription> <CardDescription>{t('settings.privacy.description')}</CardDescription>
</CardHeader> </CardHeader>
@@ -249,7 +249,7 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.privacy.error_reporting')}</Label> <Label>{t('settings.privacy.error_reporting')}</Label>
<p className="text-xs text-gray-500"> <p className="text-muted-foreground text-xs">
{t('settings.privacy.error_reporting_desc')} {t('settings.privacy.error_reporting_desc')}
</p> </p>
</div> </div>
@@ -267,8 +267,8 @@ function SettingsPage() {
</Card> </Card>
{/* Notifications Card */} {/* Notifications Card */}
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.notifications.title')}</CardTitle> <CardTitle>{t('settings.notifications.title')}</CardTitle>
<CardDescription>{t('settings.notifications.description')}</CardDescription> <CardDescription>{t('settings.notifications.description')}</CardDescription>
</CardHeader> </CardHeader>
@@ -276,7 +276,7 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.notifications.quotaAlert')}</Label> <Label>{t('settings.notifications.quotaAlert')}</Label>
<p className="text-xs text-gray-500"> <p className="text-muted-foreground text-xs">
{t('settings.notifications.quotaAlertDesc')} {t('settings.notifications.quotaAlertDesc')}
</p> </p>
</div> </div>
@@ -300,7 +300,7 @@ function SettingsPage() {
<div className="flex items-center justify-between rounded-lg border p-4"> <div className="flex items-center justify-between rounded-lg border p-4">
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('settings.notifications.quotaThreshold')}</Label> <Label>{t('settings.notifications.quotaThreshold')}</Label>
<p className="text-xs text-gray-500"> <p className="text-muted-foreground text-xs">
{t('settings.notifications.quotaThresholdDesc')} {t('settings.notifications.quotaThresholdDesc')}
</p> </p>
</div> </div>
@@ -327,7 +327,7 @@ function SettingsPage() {
} }
} }
}} }}
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm" 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"
/> />
<span className="text-muted-foreground text-sm">%</span> <span className="text-muted-foreground text-sm">%</span>
</div> </div>
@@ -343,8 +343,8 @@ function SettingsPage() {
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */} {/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
<TabsContent value="proxy" className="space-y-5"> <TabsContent value="proxy" className="space-y-5">
<Card> <Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
<CardHeader> <CardHeader className="px-0 pt-4">
<CardTitle>{t('settings.proxy.title')}</CardTitle> <CardTitle>{t('settings.proxy.title')}</CardTitle>
<CardDescription>{t('settings.proxy.description')}</CardDescription> <CardDescription>{t('settings.proxy.description')}</CardDescription>
</CardHeader> </CardHeader>
+667
View File
@@ -0,0 +1,667 @@
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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ipc } from '@/ipc/manager';
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
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 { nivoTheme } from '@/lib/nivo-theme';
import {
Activity,
Layers,
Loader2,
MessageSquare,
Radio,
Zap,
} from 'lucide-react';
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
type TimeRange = '24h' | '7d' | '30d';
interface UsageBucket {
bucket: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}
interface ModelBucket {
model: string;
promptTokens: number;
completionTokens: number;
totalTokens: number;
requests: number;
}
/* ------------------------------------------------------------------ */
/* Helpers */
/* ------------------------------------------------------------------ */
function getTimeRange(r: TimeRange): { start: number; end: number } {
const now = Date.now();
const ms: Record<TimeRange, number> = {
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000,
'30d': 30 * 24 * 60 * 60 * 1000,
};
return { start: now - ms[r], end: now };
}
function getPreviousTimeRange(r: TimeRange): { start: number; end: number } {
const { start, end } = getTimeRange(r);
const duration = end - start;
return { start: start - duration, end: start };
}
function formatBucketLabel(bucket: string, range: TimeRange): string {
if (range === '24h') {
const parts = bucket.split(' ');
return parts.length === 2 ? parts[1] : bucket;
}
const d = new Date(bucket + 'T00:00:00');
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
}
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();
}
function calcTrend(
current: number,
previous: number,
): { value: number; direction: 'up' | 'down' | 'neutral' } {
if (previous === 0 && current === 0) {
return { value: 0, direction: 'neutral' };
}
if (previous === 0) {
return { value: 100, direction: 'up' };
}
const pct = ((current - previous) / previous) * 100;
if (Math.abs(pct) < 0.5) {
return { value: 0, direction: 'neutral' };
}
return { value: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' };
}
/* ------------------------------------------------------------------ */
/* Aggregation helpers */
/* ------------------------------------------------------------------ */
function sumBuckets(data: UsageBucket[]) {
return data.reduce(
(acc, row) => ({
promptTokens: acc.promptTokens + row.promptTokens,
completionTokens: acc.completionTokens + row.completionTokens,
totalTokens: acc.totalTokens + row.totalTokens,
requests: acc.requests + row.requests,
}),
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 },
);
}
/* ------------------------------------------------------------------ */
/* Main Component */
/* ------------------------------------------------------------------ */
function UsagePage() {
const [range, setRange] = useState<TimeRange>('7d');
const ALL_ACCOUNTS = '__all__';
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
const accountFilter =
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
/* ───── Current period data ───── */
const {
data: dailyData,
isLoading: dailyLoading,
error: dailyError,
refetch: refetchDaily,
} = useQuery<UsageBucket[]>({
queryKey: ['usage', 'day', range, selectedAccountId],
queryFn: () => {
const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByDay({
accountId: accountFilter,
start,
end,
});
},
refetchInterval: 15_000,
refetchIntervalInBackground: false,
staleTime: 30_000,
});
const { data: prevDailyData } = useQuery<UsageBucket[]>({
queryKey: ['usage', 'day', 'prev', range, selectedAccountId],
queryFn: () => {
const { start, end } = getPreviousTimeRange(range);
return ipc.client.usage.getUsageByDay({
accountId: accountFilter,
start,
end,
});
},
staleTime: 60_000,
});
const {
data: modelData,
isLoading: modelLoading,
error: modelError,
refetch: refetchModel,
} = useQuery<ModelBucket[]>({
queryKey: ['usage', 'model', range, selectedAccountId],
queryFn: () => {
const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByModel({
accountId: accountFilter,
start,
end,
});
},
refetchInterval: 15_000,
refetchIntervalInBackground: false,
staleTime: 30_000,
});
const {
data: hourlyData,
isLoading: hourlyLoading,
error: hourlyError,
refetch: refetchHourly,
} = useQuery<UsageBucket[]>({
queryKey: ['usage', 'hour', range, selectedAccountId],
queryFn: () => {
const { start, end } = getTimeRange(range);
return ipc.client.usage.getUsageByHour({
accountId: accountFilter,
start,
end,
});
},
enabled: range === '24h',
refetchInterval: 15_000,
refetchIntervalInBackground: false,
staleTime: 30_000,
});
/* ───── Derived data ───── */
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
const prevTotals = useMemo(
() => sumBuckets(prevDailyData || []),
[prevDailyData],
);
const promptRatio = totals.totalTokens
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
: 0;
const completionRatio = totals.totalTokens
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
: 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 sparklineCompletion = useMemo(
() => (dailyData || []).map((d) => d.completionTokens),
[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',
color: COLOR_PROMPT,
data: source.map((d) => ({
x: formatBucketLabel(d.bucket, range),
y: d.promptTokens,
})),
},
{
id: 'Completion',
color: COLOR_COMPLETION,
data: source.map((d) => ({
x: formatBucketLabel(d.bucket, range),
y: d.completionTokens,
})),
},
];
}, [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]);
// Top models mini-list
const topModels = useMemo(() => {
if (!modelData?.length) {
return [];
}
return [...modelData]
.sort((a, b) => b.totalTokens - a.totalTokens)
.slice(0, 5);
}, [modelData]);
const isAreaEmpty =
range === '24h'
? !hourlyLoading && !hourlyError && !hourlyData?.length
: !dailyLoading && !dailyError && !dailyData?.length;
const areaLoading = range === '24h' ? hourlyLoading : dailyLoading;
const areaError = range === '24h' ? hourlyError : dailyError;
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
/* ───── Render ───── */
return (
<div className="flex h-full flex-col overflow-auto">
{/* ─── Header ─── */}
<div className="flex flex-col gap-4 border-b border-white/[0.06] px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="flex items-center gap-2">
<h1 className="text-foreground text-lg font-semibold tracking-tight">
Usage Analytics
</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>
</div>
</div>
<p className="text-muted-foreground mt-0.5 text-[13px]">
Token consumption and model distribution
</p>
</div>
<div className="flex items-center gap-3">
<Select
value={selectedAccountId}
onValueChange={setSelectedAccountId}
disabled={accountsLoading}
>
<SelectTrigger className="h-8 w-[180px] text-xs">
{accountsLoading ? (
<span className="flex items-center gap-2">
<Loader2 className="h-3 w-3 animate-spin" />
Loading…
</span>
) : (
<SelectValue placeholder="All accounts" />
)}
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_ACCOUNTS}>All accounts</SelectItem>
{accounts?.map((acc) => (
<SelectItem key={acc.id} value={acc.id}>
{acc.email}
</SelectItem>
))}
</SelectContent>
</Select>
<Tabs
value={range}
onValueChange={(v) => setRange(v as TimeRange)}
>
<TabsList className="h-8">
<TabsTrigger value="24h" className="px-3 text-xs">
24h
</TabsTrigger>
<TabsTrigger value="7d" className="px-3 text-xs">
7d
</TabsTrigger>
<TabsTrigger value="30d" className="px-3 text-xs">
30d
</TabsTrigger>
</TabsList>
</Tabs>
</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}
/>
</div>
{/* ─── 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="flex items-center justify-between">
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
Prompt / Completion
</span>
<span className="font-mono text-xs text-muted-foreground">
{formatNumber(totals.totalTokens)} total
</span>
</div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className="h-full rounded-l-full transition-all duration-500"
style={{
width: `${promptRatio}%`,
backgroundColor: COLOR_PROMPT,
}}
/>
<div
className="h-full rounded-r-full transition-all duration-500"
style={{
width: `${completionRatio}%`,
backgroundColor: COLOR_COMPLETION,
}}
/>
</div>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span
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>
</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">
{completionRatio}%
</span>
</div>
</div>
<span className="text-muted-foreground">
Avg/req:{' '}
<span className="font-mono font-medium text-foreground">
{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>
</div>
)}
{/* ─── Charts — Area + Model ranking ─── */}
<div className="grid grid-cols-1 gap-3 xl:grid-cols-5">
<div className="xl:col-span-3">
<ChartCard
title={range === '24h' ? 'Hourly Usage' : 'Daily Usage'}
isLoading={areaLoading}
error={areaError}
isEmpty={isAreaEmpty}
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"
retryLabel="Retry"
>
<div className="h-[320px]">
<ResponsiveLine
data={areaChartData}
theme={nivoTheme}
colors={[COLOR_PROMPT, COLOR_COMPLETION]}
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
xScale={{ type: 'point' }}
yScale={{ type: 'linear', min: 0, stacked: false }}
curve="monotoneX"
enableArea={true}
areaOpacity={0.08}
areaBlendMode="normal"
enablePoints={true}
pointSize={4}
pointColor={{ theme: 'background' }}
pointBorderWidth={2}
pointBorderColor={{ from: 'serieColor' }}
enableGridX={false}
enableGridY={true}
gridYValues={5}
axisBottom={{
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
}}
axisLeft={{
tickSize: 0,
tickPadding: 8,
tickRotation: 0,
format: (v: number) =>
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
}}
tooltip={LineTooltip}
legends={[
{
anchor: 'bottom',
direction: 'row',
justify: false,
translateX: 0,
translateY: 36,
itemsSpacing: 16,
itemWidth: 80,
itemHeight: 16,
itemDirection: 'left-to-right',
symbolSize: 8,
symbolShape: 'circle',
},
]}
animate={true}
motionConfig="gentle"
/>
</div>
</ChartCard>
</div>
<div className="xl:col-span-2">
<ChartCard
title="Model Distribution"
isLoading={modelLoading}
error={modelError}
isEmpty={isModelEmpty}
onRetry={() => refetchModel()}
emptyTitle="No model data"
emptyDescription="Model distribution will appear after proxy usage."
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>
</ChartCard>
</div>
</div>
</div>
</div>
);
}
export const Route = createFileRoute('/usage')({
component: UsagePage,
});
+7 -2
View File
@@ -82,12 +82,17 @@ export function safeApiLogValue(value: unknown, maxLength = 50_000): unknown {
const sanitized = sanitizeObject(value); const sanitized = sanitizeObject(value);
if (typeof sanitized === 'string' && sanitized.length > maxLength) { if (typeof sanitized === 'string' && sanitized.length > maxLength) {
return sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`; return (
sanitized.substring(0, maxLength) + `... [truncated ${sanitized.length - maxLength} chars]`
);
} }
const stringified = JSON.stringify(sanitized); const stringified = JSON.stringify(sanitized);
if (stringified.length > maxLength) { if (stringified.length > maxLength) {
return stringified.substring(0, maxLength) + `... [truncated ${stringified.length - maxLength} chars]`; return (
stringified.substring(0, maxLength) +
`... [truncated ${stringified.length - maxLength} chars]`
);
} }
return sanitized; return sanitized;
Loaded 100 of 395 files, more files were not shown because too many files have changed in this diff. Show more