Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a6587cb8b | ||
|
|
e1c9a9e34a | ||
|
|
1c26a9e542 | ||
|
|
5026be12d2 | ||
|
|
c05daba98b | ||
|
|
2e3a65b76b | ||
|
|
62634b5511 | ||
|
|
27223f587d | ||
|
|
1d35801980 | ||
|
|
269ad18ed6 | ||
|
|
9b9b977ae6 | ||
|
|
f4daaa8ca1 | ||
|
|
e221ad14cd | ||
|
|
268428a119 | ||
|
|
080bf30e38 | ||
|
|
c134a072df | ||
|
|
a905877c76 | ||
|
|
3752dfda91 | ||
|
|
71bf8122d1 | ||
|
|
713c4497c6 | ||
|
|
641c739b64 | ||
|
|
99e458f69b | ||
|
|
bc69a2e8b5 | ||
|
|
20785a8414 | ||
|
|
5c44a622a8 | ||
|
|
bf5a42c03e | ||
|
|
7d2977b665 | ||
|
|
0753bbdce6 |
No files matched your search
@@ -18,8 +18,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ name: Publish Release
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_run:
|
workflow_run:
|
||||||
workflows: ["Release"]
|
workflows: ['Release']
|
||||||
types: [completed]
|
types: [completed]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
@@ -92,8 +92,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Install Node and NPM
|
- name: Install Node and NPM
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -23,8 +23,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -44,8 +42,6 @@ jobs:
|
|||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v4
|
uses: pnpm/action-setup@v4
|
||||||
with:
|
|
||||||
version: 10
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
|
|||||||
+28
@@ -126,3 +126,31 @@ accounts_backup.json
|
|||||||
|
|
||||||
# Bun lock file
|
# Bun lock file
|
||||||
bun.lock
|
bun.lock
|
||||||
|
|
||||||
|
# Build tools (installed via CI)
|
||||||
|
tools/
|
||||||
|
*.zip
|
||||||
|
|
||||||
|
# AI agent session data
|
||||||
|
thoughts/
|
||||||
|
.impeccable.md
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# One-shot scripts (already applied)
|
||||||
|
fix-ts.js
|
||||||
|
patch-proxy.js
|
||||||
|
patch-settings.js
|
||||||
|
rename-deep.js
|
||||||
|
test-process.ts
|
||||||
|
|
||||||
|
# Legacy CLI launchers
|
||||||
|
agm.bat
|
||||||
|
run_cli.bat
|
||||||
|
|
||||||
|
# Nix (not used)
|
||||||
|
flake.nix
|
||||||
|
flake.lock
|
||||||
|
|
||||||
|
# Planning/spec data
|
||||||
|
.planning/
|
||||||
|
openspec/
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
## Design Context
|
|
||||||
|
|
||||||
### Users
|
|
||||||
|
|
||||||
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
|
|
||||||
|
|
||||||
### Brand Personality
|
|
||||||
|
|
||||||
- **Professional**: Clean, precise, no clutter
|
|
||||||
- **Technical**: Data-dense but readable, developer-friendly
|
|
||||||
- **Efficient**: Information at a glance, minimal clicks
|
|
||||||
- 3 words: "precise", "technical", "focused"
|
|
||||||
|
|
||||||
### Aesthetic Direction
|
|
||||||
|
|
||||||
- Dark mode primary (this is a developer tool used for long sessions)
|
|
||||||
- Deep navy/slate backgrounds, not pure black
|
|
||||||
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
|
|
||||||
- High information density without visual noise
|
|
||||||
- Monospace for numbers/data, clean sans-serif for UI chrome
|
|
||||||
- No gradients on text, no glassmorphism, no rounded-everything
|
|
||||||
|
|
||||||
### Design Principles
|
|
||||||
|
|
||||||
1. Data first — charts and numbers are the heroes
|
|
||||||
2. Every pixel earns its place — no decorative elements
|
|
||||||
3. Density over whitespace — developers want to see everything
|
|
||||||
4. Motion only for state changes — no gratuitous animations
|
|
||||||
5. Consistent with shadcn/ui + Tailwind system already in place
|
|
||||||
@@ -4,17 +4,20 @@
|
|||||||
> Date: 2026-04-22
|
> Date: 2026-04-22
|
||||||
|
|
||||||
## Current State
|
## 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.
|
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
|
## Proposed New Features
|
||||||
|
|
||||||
### 1. Live Traffic Monitor
|
### 1. Live Traffic Monitor
|
||||||
|
|
||||||
- Real-time SSE/WebSocket feed of proxied requests
|
- Real-time SSE/WebSocket feed of proxied requests
|
||||||
- Show request method, model, account used, latency, token count
|
- Show request method, model, account used, latency, token count
|
||||||
- Filter by status (success, rate-limited, error)
|
- Filter by status (success, rate-limited, error)
|
||||||
- Inspired by: Request logging already exists in `TrafficLogger`
|
- Inspired by: Request logging already exists in `TrafficLogger`
|
||||||
|
|
||||||
### 2. IDE Quick-Connect Panel
|
### 2. IDE Quick-Connect Panel
|
||||||
|
|
||||||
- One-click copy of ready-to-use configurations for:
|
- One-click copy of ready-to-use configurations for:
|
||||||
- Cursor (`settings.json` snippet)
|
- Cursor (`settings.json` snippet)
|
||||||
- OpenCode (`.env` snippet)
|
- OpenCode (`.env` snippet)
|
||||||
@@ -24,6 +27,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- QR code for mobile/quick access
|
- QR code for mobile/quick access
|
||||||
|
|
||||||
### 3. API Playground
|
### 3. API Playground
|
||||||
|
|
||||||
- Built-in Swagger/OpenAPI UI or custom test panel
|
- Built-in Swagger/OpenAPI UI or custom test panel
|
||||||
- Pre-loaded with all supported endpoints:
|
- Pre-loaded with all supported endpoints:
|
||||||
- `/v1/chat/completions` (OpenAI-compatible)
|
- `/v1/chat/completions` (OpenAI-compatible)
|
||||||
@@ -33,6 +37,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- Show exact curl equivalent for every request
|
- Show exact curl equivalent for every request
|
||||||
|
|
||||||
### 4. Health Dashboard
|
### 4. Health Dashboard
|
||||||
|
|
||||||
- Real-time metrics cards:
|
- Real-time metrics cards:
|
||||||
- Requests/minute
|
- Requests/minute
|
||||||
- Average latency (p50, p95, p99)
|
- Average latency (p50, p95, p99)
|
||||||
@@ -43,6 +48,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- Color-coded status indicators
|
- Color-coded status indicators
|
||||||
|
|
||||||
### 5. Model Availability Status
|
### 5. Model Availability Status
|
||||||
|
|
||||||
- Live grid showing which models are currently available
|
- Live grid showing which models are currently available
|
||||||
- Per-model indicators:
|
- Per-model indicators:
|
||||||
- Green: available
|
- Green: available
|
||||||
@@ -52,6 +58,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- Collapse/expand by provider (Gemini, Anthropic)
|
- Collapse/expand by provider (Gemini, Anthropic)
|
||||||
|
|
||||||
### 6. Error Feed
|
### 6. Error Feed
|
||||||
|
|
||||||
- Scrollable log of recent errors
|
- Scrollable log of recent errors
|
||||||
- Columns: timestamp, endpoint, model, account, error code, message
|
- Columns: timestamp, endpoint, model, account, error code, message
|
||||||
- Click to expand full request/response
|
- Click to expand full request/response
|
||||||
@@ -59,6 +66,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- Export to JSON/CSV
|
- Export to JSON/CSV
|
||||||
|
|
||||||
### 7. Connection Visualizer
|
### 7. Connection Visualizer
|
||||||
|
|
||||||
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
|
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
|
||||||
- Animated flow dots representing live requests
|
- Animated flow dots representing live requests
|
||||||
- Highlight currently active account
|
- Highlight currently active account
|
||||||
@@ -66,6 +74,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- Simple but effective for understanding routing
|
- Simple but effective for understanding routing
|
||||||
|
|
||||||
### 8. One-Click Diagnostics
|
### 8. One-Click Diagnostics
|
||||||
|
|
||||||
- "Run Diagnostics" button that performs:
|
- "Run Diagnostics" button that performs:
|
||||||
- Check if proxy port is accessible
|
- Check if proxy port is accessible
|
||||||
- Test each configured account
|
- Test each configured account
|
||||||
@@ -75,6 +84,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
- Output: green checkmarks or detailed error report with fix suggestions
|
- Output: green checkmarks or detailed error report with fix suggestions
|
||||||
|
|
||||||
### 9. Session Stats
|
### 9. Session Stats
|
||||||
|
|
||||||
- Summary of current session since proxy started:
|
- Summary of current session since proxy started:
|
||||||
- Total requests handled
|
- Total requests handled
|
||||||
- Total tokens proxied
|
- Total tokens proxied
|
||||||
@@ -86,6 +96,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
|
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
|
||||||
- Health metrics likely already exist in `ProxyService` but need exposure
|
- Health metrics likely already exist in `ProxyService` but need exposure
|
||||||
- Traffic logging exists via `TrafficLogger` — just needs UI
|
- Traffic logging exists via `TrafficLogger` — just needs UI
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
> Date: 2026-04-22
|
> Date: 2026-04-22
|
||||||
|
|
||||||
## Current Pain Points
|
## Current Pain Points
|
||||||
|
|
||||||
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
|
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
|
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
|
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
|
||||||
@@ -14,29 +15,35 @@
|
|||||||
## Proposed Improvements
|
## Proposed Improvements
|
||||||
|
|
||||||
### 1. Remove Gradient Backgrounds
|
### 1. Remove Gradient Backgrounds
|
||||||
|
|
||||||
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
|
- 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
|
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
|
||||||
- Align with the rest of the app which uses flat, technical styling
|
- Align with the rest of the app which uses flat, technical styling
|
||||||
|
|
||||||
### 2. Tabs-Based Layout
|
### 2. Tabs-Based Layout
|
||||||
|
|
||||||
Organize the page into tabs:
|
Organize the page into tabs:
|
||||||
|
|
||||||
- **Status** (default): Service state, quick actions, health metrics
|
- **Status** (default): Service state, quick actions, health metrics
|
||||||
- **Routing**: Model mapping, account scheduling, protocol settings
|
- **Routing**: Model mapping, account scheduling, protocol settings
|
||||||
- **API Docs**: Code examples, endpoint documentation, IDE configs
|
- **API Docs**: Code examples, endpoint documentation, IDE configs
|
||||||
- **Logs**: Live traffic feed, error history
|
- **Logs**: Live traffic feed, error history
|
||||||
|
|
||||||
### 3. Compact Protocol Selector
|
### 3. Compact Protocol Selector
|
||||||
|
|
||||||
- Current: large buttons with icons
|
- Current: large buttons with icons
|
||||||
- Proposed: `Select` dropdown or compact segmented control
|
- Proposed: `Select` dropdown or compact segmented control
|
||||||
- Save horizontal space for more important controls
|
- Save horizontal space for more important controls
|
||||||
|
|
||||||
### 4. Collapsible Code Examples
|
### 4. Collapsible Code Examples
|
||||||
|
|
||||||
- Wrap each code block in a `Collapsible` or `Accordion`
|
- Wrap each code block in a `Collapsible` or `Accordion`
|
||||||
- Show only the title + language icon by default
|
- Show only the title + language icon by default
|
||||||
- Expand on click
|
- Expand on click
|
||||||
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
|
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
|
||||||
|
|
||||||
### 5. Security-Focused API Key Display
|
### 5. Security-Focused API Key Display
|
||||||
|
|
||||||
- Mask the API key by default (show `sk-••••••••`)
|
- Mask the API key by default (show `sk-••••••••`)
|
||||||
- Reveal on explicit "Show" toggle
|
- Reveal on explicit "Show" toggle
|
||||||
- Add "Regenerate" button with confirmation dialog
|
- Add "Regenerate" button with confirmation dialog
|
||||||
@@ -44,6 +51,7 @@ Organize the page into tabs:
|
|||||||
- Add warning if key is the default/weak key
|
- Add warning if key is the default/weak key
|
||||||
|
|
||||||
### 6. Configurable Model Mapping Table
|
### 6. Configurable Model Mapping Table
|
||||||
|
|
||||||
- Instead of a static list, show an editable table:
|
- Instead of a static list, show an editable table:
|
||||||
- Requested model | Resolved model | Provider | Status
|
- Requested model | Resolved model | Provider | Status
|
||||||
- Allow users to add custom mappings
|
- Allow users to add custom mappings
|
||||||
@@ -51,21 +59,25 @@ Organize the page into tabs:
|
|||||||
- Validate that resolved models exist in the account pool
|
- Validate that resolved models exist in the account pool
|
||||||
|
|
||||||
### 7. Account Quota Context
|
### 7. Account Quota Context
|
||||||
|
|
||||||
- Show the currently selected "primary" account with its quota bar
|
- Show the currently selected "primary" account with its quota bar
|
||||||
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
|
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
|
||||||
- Link to the account pool page for detailed management
|
- Link to the account pool page for detailed management
|
||||||
|
|
||||||
### 8. Toast Copy Feedback
|
### 8. Toast Copy Feedback
|
||||||
|
|
||||||
- When user clicks "Copy", show a `Toast` or `Sonner` notification
|
- When user clicks "Copy", show a `Toast` or `Sonner` notification
|
||||||
- Current: no feedback — user doesn't know if copy succeeded
|
- Current: no feedback — user doesn't know if copy succeeded
|
||||||
- Message: "Copied to clipboard" or "Failed to copy"
|
- Message: "Copied to clipboard" or "Failed to copy"
|
||||||
|
|
||||||
### 9. Health Metrics Stub
|
### 9. Health Metrics Stub
|
||||||
|
|
||||||
- Even if backend metrics aren't wired yet, create the UI skeleton
|
- Even if backend metrics aren't wired yet, create the UI skeleton
|
||||||
- Show placeholder charts with "Coming soon" badge
|
- Show placeholder charts with "Coming soon" badge
|
||||||
- This sets expectations and makes the page feel more complete
|
- This sets expectations and makes the page feel more complete
|
||||||
|
|
||||||
### 10. Empty States
|
### 10. Empty States
|
||||||
|
|
||||||
- When proxy is stopped, show a clear empty state:
|
- When proxy is stopped, show a clear empty state:
|
||||||
- Icon: `ServerOff` from lucide
|
- Icon: `ServerOff` from lucide
|
||||||
- Message: "Proxy is not running"
|
- Message: "Proxy is not running"
|
||||||
@@ -75,6 +87,7 @@ Organize the page into tabs:
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Design Principles
|
## Design Principles
|
||||||
|
|
||||||
- **Density over whitespace**: This is a developer tool — show more information per pixel
|
- **Density over whitespace**: This is a developer tool — show more information per pixel
|
||||||
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
|
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
|
||||||
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
|
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
|
||||||
|
|||||||
@@ -4,13 +4,16 @@
|
|||||||
> Date: 2026-04-22
|
> Date: 2026-04-22
|
||||||
|
|
||||||
## Discovery
|
## 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.
|
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
|
## Hidden Backend Features
|
||||||
|
|
||||||
### 1. Advanced Routing & Scheduling Controls
|
### 1. Advanced Routing & Scheduling Controls
|
||||||
|
|
||||||
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
|
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
|
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
|
||||||
- `scheduler.maxWaitTime`: Max time to wait for an available account
|
- `scheduler.maxWaitTime`: Max time to wait for an available account
|
||||||
- `scheduler.retryAttempts`: Number of retries per request
|
- `scheduler.retryAttempts`: Number of retries per request
|
||||||
@@ -20,8 +23,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None. These are hardcoded or set via config files only.
|
**UI exposure**: None. These are hardcoded or set via config files only.
|
||||||
|
|
||||||
### 2. Live Traffic Inspector
|
### 2. Live Traffic Inspector
|
||||||
|
|
||||||
**Backend location**: `traffic-logger.service.ts`
|
**Backend location**: `traffic-logger.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- `TrafficLogger` captures every request/response with metadata
|
- `TrafficLogger` captures every request/response with metadata
|
||||||
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
|
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
|
||||||
- Log rotation and cleanup policies
|
- Log rotation and cleanup policies
|
||||||
@@ -30,8 +35,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None. Logs are only written to files.
|
**UI exposure**: None. Logs are only written to files.
|
||||||
|
|
||||||
### 3. Circuit Breaker Dashboard
|
### 3. Circuit Breaker Dashboard
|
||||||
|
|
||||||
**Backend location**: `proxy-circuit-breaker.service.ts`
|
**Backend location**: `proxy-circuit-breaker.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
|
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
|
||||||
- Failure threshold and recovery timeout
|
- Failure threshold and recovery timeout
|
||||||
- When open: requests fail fast without hitting the API
|
- When open: requests fail fast without hitting the API
|
||||||
@@ -40,8 +47,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None. Circuit breaker state is internal only.
|
**UI exposure**: None. Circuit breaker state is internal only.
|
||||||
|
|
||||||
### 4. Parity Testing Sandbox
|
### 4. Parity Testing Sandbox
|
||||||
|
|
||||||
**Backend location**: `proxy-parity.service.ts`
|
**Backend location**: `proxy-parity.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- Send identical requests to multiple models simultaneously
|
- Send identical requests to multiple models simultaneously
|
||||||
- Compare responses for consistency
|
- Compare responses for consistency
|
||||||
- Detect model drift or API changes
|
- Detect model drift or API changes
|
||||||
@@ -50,8 +59,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None. Only accessible via manual API calls or scripts.
|
**UI exposure**: None. Only accessible via manual API calls or scripts.
|
||||||
|
|
||||||
### 5. Custom Model Mapping Table
|
### 5. Custom Model Mapping Table
|
||||||
|
|
||||||
**Backend location**: `proxy-model-mapper.service.ts`
|
**Backend location**: `proxy-model-mapper.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
|
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
|
||||||
- User-defined mappings stored in database
|
- User-defined mappings stored in database
|
||||||
- Regex-based model matching
|
- Regex-based model matching
|
||||||
@@ -60,8 +71,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: A static, read-only list is shown. No editing capability.
|
**UI exposure**: A static, read-only list is shown. No editing capability.
|
||||||
|
|
||||||
### 6. Upstream Proxy Configuration
|
### 6. Upstream Proxy Configuration
|
||||||
|
|
||||||
**Backend location**: `proxy-upstream.service.ts`
|
**Backend location**: `proxy-upstream.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- `upstreamProxy.url`: Route all traffic through another proxy
|
- `upstreamProxy.url`: Route all traffic through another proxy
|
||||||
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
|
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
|
||||||
- `upstreamProxy.bypassList`: Domains to bypass
|
- `upstreamProxy.bypassList`: Domains to bypass
|
||||||
@@ -69,8 +82,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None. Set via environment variables only.
|
**UI exposure**: None. Set via environment variables only.
|
||||||
|
|
||||||
### 7. IDE Integration Quick-Setup
|
### 7. IDE Integration Quick-Setup
|
||||||
|
|
||||||
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
|
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- Generates ready-to-paste configs for:
|
- Generates ready-to-paste configs for:
|
||||||
- Cursor (`settings.json`)
|
- Cursor (`settings.json`)
|
||||||
- VS Code + Cline/Roo Code
|
- VS Code + Cline/Roo Code
|
||||||
@@ -82,8 +97,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
|
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
|
||||||
|
|
||||||
### 8. Health & Performance Metrics
|
### 8. Health & Performance Metrics
|
||||||
|
|
||||||
**Backend location**: `proxy-metrics.service.ts`
|
**Backend location**: `proxy-metrics.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- Request latency histogram (p50, p95, p99)
|
- Request latency histogram (p50, p95, p99)
|
||||||
- Requests per minute counter
|
- Requests per minute counter
|
||||||
- Error rate by status code
|
- Error rate by status code
|
||||||
@@ -94,8 +111,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
|
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
|
||||||
|
|
||||||
### 9. Request Replay & Debug Tool
|
### 9. Request Replay & Debug Tool
|
||||||
|
|
||||||
**Backend location**: `proxy-replay.service.ts`
|
**Backend location**: `proxy-replay.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- Store recent requests (last 100 by default)
|
- Store recent requests (last 100 by default)
|
||||||
- Replay any request with one click
|
- Replay any request with one click
|
||||||
- Modify headers/body before replay
|
- Modify headers/body before replay
|
||||||
@@ -105,8 +124,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
**UI exposure**: None.
|
**UI exposure**: None.
|
||||||
|
|
||||||
### 10. Global Timeout & Max Wait Controls
|
### 10. Global Timeout & Max Wait Controls
|
||||||
|
|
||||||
**Backend location**: `proxy-config.service.ts`
|
**Backend location**: `proxy-config.service.ts`
|
||||||
**What's implemented**:
|
**What's implemented**:
|
||||||
|
|
||||||
- `requestTimeout`: Global timeout for all upstream requests
|
- `requestTimeout`: Global timeout for all upstream requests
|
||||||
- `connectTimeout`: TCP connection timeout
|
- `connectTimeout`: TCP connection timeout
|
||||||
- `maxWaitTime`: Max time to wait for an available account
|
- `maxWaitTime`: Max time to wait for an available account
|
||||||
@@ -117,7 +138,9 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Recommendation
|
## Recommendation
|
||||||
|
|
||||||
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
|
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)
|
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
|
||||||
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
|
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
|
||||||
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
|
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
> Date: 2026-04-22
|
> Date: 2026-04-22
|
||||||
|
|
||||||
## Current State
|
## Current State
|
||||||
|
|
||||||
`src/routes/proxy.tsx` is a **724-line monolith** containing:
|
`src/routes/proxy.tsx` is a **724-line monolith** containing:
|
||||||
|
|
||||||
- Route definition
|
- Route definition
|
||||||
- State management (useState, useEffect, useMemo)
|
- State management (useState, useEffect, useMemo)
|
||||||
- Data fetching (useQuery equivalents for proxy config, local IPs)
|
- Data fetching (useQuery equivalents for proxy config, local IPs)
|
||||||
@@ -14,6 +16,7 @@
|
|||||||
- Constants and helper functions
|
- Constants and helper functions
|
||||||
|
|
||||||
## Target Architecture
|
## Target Architecture
|
||||||
|
|
||||||
Split into **7+ files** following the project's conventions:
|
Split into **7+ files** following the project's conventions:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -39,7 +42,9 @@ src/
|
|||||||
## File Breakdown
|
## File Breakdown
|
||||||
|
|
||||||
### 1. `src/hooks/useProxyConfig.ts`
|
### 1. `src/hooks/useProxyConfig.ts`
|
||||||
|
|
||||||
**Responsibility**: Fetch and mutate proxy configuration
|
**Responsibility**: Fetch and mutate proxy configuration
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function useProxyConfig() {
|
export function useProxyConfig() {
|
||||||
// Fetches from ipc.client.proxy.getConfig()
|
// Fetches from ipc.client.proxy.getConfig()
|
||||||
@@ -49,7 +54,9 @@ export function useProxyConfig() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. `src/hooks/useLocalIps.ts`
|
### 2. `src/hooks/useLocalIps.ts`
|
||||||
|
|
||||||
**Responsibility**: Get local IP addresses for the access banner
|
**Responsibility**: Get local IP addresses for the access banner
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function useLocalIps() {
|
export function useLocalIps() {
|
||||||
// Fetches from ipc.client.system.getLocalIps()
|
// Fetches from ipc.client.system.getLocalIps()
|
||||||
@@ -58,7 +65,9 @@ export function useLocalIps() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. `src/hooks/useProxyExamples.ts`
|
### 3. `src/hooks/useProxyExamples.ts`
|
||||||
|
|
||||||
**Responsibility**: Generate code examples based on current config
|
**Responsibility**: Generate code examples based on current config
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||||
// Returns memoized examples for curl, Python, Node.js
|
// Returns memoized examples for curl, Python, Node.js
|
||||||
@@ -67,7 +76,9 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. `src/components/proxy/ProxyServiceCard.tsx`
|
### 4. `src/components/proxy/ProxyServiceCard.tsx`
|
||||||
|
|
||||||
**Responsibility**: The main service control card
|
**Responsibility**: The main service control card
|
||||||
|
|
||||||
- Start/stop toggle
|
- Start/stop toggle
|
||||||
- Port input
|
- Port input
|
||||||
- Protocol selector
|
- Protocol selector
|
||||||
@@ -75,26 +86,34 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
|||||||
- Status indicator
|
- Status indicator
|
||||||
|
|
||||||
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
|
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
|
||||||
|
|
||||||
**Responsibility**: Model mapping display/editor
|
**Responsibility**: Model mapping display/editor
|
||||||
|
|
||||||
- Read-only list (current state)
|
- Read-only list (current state)
|
||||||
- Future: editable table with add/remove
|
- Future: editable table with add/remove
|
||||||
- Show provider badges
|
- Show provider badges
|
||||||
|
|
||||||
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
|
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
|
||||||
|
|
||||||
**Responsibility**: Code examples section
|
**Responsibility**: Code examples section
|
||||||
|
|
||||||
- Collapsible/accordion layout
|
- Collapsible/accordion layout
|
||||||
- Tabs for different languages
|
- Tabs for different languages
|
||||||
- Copy-to-clipboard buttons
|
- Copy-to-clipboard buttons
|
||||||
- Syntax highlighting (if available)
|
- Syntax highlighting (if available)
|
||||||
|
|
||||||
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
|
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
|
||||||
|
|
||||||
**Responsibility**: "Access your proxy locally" banner
|
**Responsibility**: "Access your proxy locally" banner
|
||||||
|
|
||||||
- Show local IPs
|
- Show local IPs
|
||||||
- QR code (future)
|
- QR code (future)
|
||||||
- Copy URL buttons
|
- Copy URL buttons
|
||||||
|
|
||||||
### 8. `src/constants/proxy.ts`
|
### 8. `src/constants/proxy.ts`
|
||||||
|
|
||||||
**Responsibility**: Constants
|
**Responsibility**: Constants
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export const DEFAULT_PROXY_PORT = 3000;
|
export const DEFAULT_PROXY_PORT = 3000;
|
||||||
export const PROTOCOLS = ['http', 'https'] as const;
|
export const PROTOCOLS = ['http', 'https'] as const;
|
||||||
@@ -102,7 +121,9 @@ export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 9. `src/utils/proxy.ts`
|
### 9. `src/utils/proxy.ts`
|
||||||
|
|
||||||
**Responsibility**: Pure helper functions
|
**Responsibility**: Pure helper functions
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
|
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
|
||||||
export function maskApiKey(key: string): string;
|
export function maskApiKey(key: string): string;
|
||||||
@@ -141,6 +162,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Migration Steps
|
## Migration Steps
|
||||||
|
|
||||||
1. Create new files (hooks, components, constants, utils)
|
1. Create new files (hooks, components, constants, utils)
|
||||||
2. Move code from `proxy.tsx` to appropriate files
|
2. Move code from `proxy.tsx` to appropriate files
|
||||||
3. Update imports in `proxy.tsx`
|
3. Update imports in `proxy.tsx`
|
||||||
@@ -149,6 +171,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
|||||||
6. Verify no regression in functionality
|
6. Verify no regression in functionality
|
||||||
|
|
||||||
## Benefits
|
## Benefits
|
||||||
|
|
||||||
- **Testability**: Each hook and component can be unit tested in isolation
|
- **Testability**: Each hook and component can be unit tested in isolation
|
||||||
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
|
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
|
||||||
- **Maintainability**: 50-line route file vs. 724-line monolith
|
- **Maintainability**: 50-line route file vs. 724-line monolith
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
> Date: 2026-04-22
|
> Date: 2026-04-22
|
||||||
|
|
||||||
## Severity Legend
|
## Severity Legend
|
||||||
|
|
||||||
- 🔴 **Critical**: Bugs or crashes
|
- 🔴 **Critical**: Bugs or crashes
|
||||||
- 🟡 **High**: Performance or reliability issues
|
- 🟡 **High**: Performance or reliability issues
|
||||||
- 🟢 **Medium**: Maintainability or best practice violations
|
- 🟢 **Medium**: Maintainability or best practice violations
|
||||||
@@ -11,21 +12,26 @@
|
|||||||
## Issues Found
|
## Issues Found
|
||||||
|
|
||||||
### 1. 🔴 State Duplication Between Proxy Config and Local State
|
### 1. 🔴 State Duplication Between Proxy Config and Local State
|
||||||
|
|
||||||
**Location**: Lines ~120-180
|
**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.
|
**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:
|
**Fix**: Use a single source of truth. Either:
|
||||||
|
|
||||||
- Derive local state from `proxyConfig` with `useEffect` sync
|
- Derive local state from `proxyConfig` with `useEffect` sync
|
||||||
- Or use `useQuery` with `staleTime: 0` and no local cache
|
- Or use `useQuery` with `staleTime: 0` and no local cache
|
||||||
|
|
||||||
### 2. 🟡 Race Condition in Start/Stop Toggle
|
### 2. 🟡 Race Condition in Start/Stop Toggle
|
||||||
|
|
||||||
**Location**: Lines ~200-250
|
**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.
|
**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.
|
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
|
||||||
|
|
||||||
### 3. 🟢 Clipboard Error Handling
|
### 3. 🟢 Clipboard Error Handling
|
||||||
|
|
||||||
**Location**: Lines ~300-320
|
**Location**: Lines ~300-320
|
||||||
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
|
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
|
||||||
**Fix**:
|
**Fix**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
async function copyToClipboard(text: string) {
|
async function copyToClipboard(text: string) {
|
||||||
try {
|
try {
|
||||||
@@ -38,18 +44,22 @@ async function copyToClipboard(text: string) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. 🟢 Unstable Callbacks Causing Re-renders
|
### 4. 🟢 Unstable Callbacks Causing Re-renders
|
||||||
|
|
||||||
**Location**: Throughout
|
**Location**: Throughout
|
||||||
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
|
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
|
||||||
**Fix**: Wrap handlers in `useCallback`:
|
**Fix**: Wrap handlers in `useCallback`:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const handleStart = useCallback(() => startProxy(), [startProxy]);
|
const handleStart = useCallback(() => startProxy(), [startProxy]);
|
||||||
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
|
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 5. 🟡 Memory Leak in setTimeout
|
### 5. 🟡 Memory Leak in setTimeout
|
||||||
|
|
||||||
**Location**: Lines ~400-420
|
**Location**: Lines ~400-420
|
||||||
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
|
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
|
||||||
**Fix**: Use `useEffect` cleanup:
|
**Fix**: Use `useEffect` cleanup:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timer = setTimeout(() => setCopied(false), 2000);
|
const timer = setTimeout(() => setCopied(false), 2000);
|
||||||
@@ -58,24 +68,29 @@ useEffect(() => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 6. 🟢 Missing Loading States for Mutations
|
### 6. 🟢 Missing Loading States for Mutations
|
||||||
|
|
||||||
**Location**: Lines ~200-250
|
**Location**: Lines ~200-250
|
||||||
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
|
**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.
|
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
|
||||||
|
|
||||||
### 7. 🟢 Hardcoded i18n Strings
|
### 7. 🟢 Hardcoded i18n Strings
|
||||||
|
|
||||||
**Location**: Throughout
|
**Location**: Throughout
|
||||||
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
|
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
|
||||||
|
|
||||||
- "Proxy Service"
|
- "Proxy Service"
|
||||||
- "Start Proxy"
|
- "Start Proxy"
|
||||||
- "Stop Proxy"
|
- "Stop Proxy"
|
||||||
- "Copy"
|
- "Copy"
|
||||||
- "Copied!"
|
- "Copied!"
|
||||||
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
||||||
|
|
||||||
### 8. 🟢 Magic Numbers
|
### 8. 🟢 Magic Numbers
|
||||||
|
|
||||||
**Location**: Lines ~450-500
|
**Location**: Lines ~450-500
|
||||||
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
|
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
|
||||||
**Fix**:
|
**Fix**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const MIN_PORT = 1024;
|
const MIN_PORT = 1024;
|
||||||
const MAX_PORT = 65535;
|
const MAX_PORT = 65535;
|
||||||
@@ -83,9 +98,11 @@ const DEFAULT_PORT = 3000;
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 9. 🟢 Unsafe Type Cast
|
### 9. 🟢 Unsafe Type Cast
|
||||||
|
|
||||||
**Location**: Lines ~550-600
|
**Location**: Lines ~550-600
|
||||||
**Issue**: `event.target.value` is cast to `Protocol` without validation.
|
**Issue**: `event.target.value` is cast to `Protocol` without validation.
|
||||||
**Fix**:
|
**Fix**:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const protocol = event.target.value;
|
const protocol = event.target.value;
|
||||||
if (!PROTOCOLS.includes(protocol)) return;
|
if (!PROTOCOLS.includes(protocol)) return;
|
||||||
@@ -93,22 +110,27 @@ setProtocol(protocol as Protocol);
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 10. 🟡 Dangerous Parsing of Local IPs
|
### 10. 🟡 Dangerous Parsing of Local IPs
|
||||||
|
|
||||||
**Location**: Lines ~650-700
|
**Location**: Lines ~650-700
|
||||||
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
|
**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.
|
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
|
||||||
|
|
||||||
### 11. 🟢 Unnecessary Re-renders from Inline Objects
|
### 11. 🟢 Unnecessary Re-renders from Inline Objects
|
||||||
|
|
||||||
**Location**: Lines ~350-380
|
**Location**: Lines ~350-380
|
||||||
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
|
**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:
|
**Fix**: Extract static styles outside the component or memoize:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 12. 🟢 No Validation on Port Input
|
### 12. 🟢 No Validation on Port Input
|
||||||
|
|
||||||
**Location**: Lines ~150-170
|
**Location**: Lines ~150-170
|
||||||
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
|
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
|
||||||
**Fix**: Add input validation:
|
**Fix**: Add input validation:
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@@ -125,6 +147,7 @@ const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Testing Recommendations
|
## Testing Recommendations
|
||||||
|
|
||||||
- Add unit tests for `useProxyConfig` hook (mock IPC client)
|
- Add unit tests for `useProxyConfig` hook (mock IPC client)
|
||||||
- Test race conditions: rapid start/stop clicks
|
- Test race conditions: rapid start/stop clicks
|
||||||
- Test clipboard failure path
|
- Test clipboard failure path
|
||||||
|
|||||||
@@ -6,16 +6,18 @@
|
|||||||
## File Map
|
## File Map
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
| File | Lines | Role |
|
| File | Lines | Role |
|
||||||
|------|-------|------|
|
| -------------------------------------------- | ----- | ---------------------------------------- |
|
||||||
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
||||||
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
||||||
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
||||||
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
||||||
|
|
||||||
### Backend (NestJS)
|
### Backend (NestJS)
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
| ----------------------------------------------------------- | ------------------------------------------- |
|
||||||
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
|
| `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.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.controller.ts` | HTTP endpoints for proxy control |
|
||||||
@@ -30,15 +32,17 @@
|
|||||||
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
||||||
|
|
||||||
### IPC Layer
|
### IPC Layer
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
| ------------------------- | ---------------------------- |
|
||||||
| `src/ipc/router.ts` | Main IPC router composition |
|
| `src/ipc/router.ts` | Main IPC router composition |
|
||||||
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
|
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
|
||||||
| `src/ipc/manager.ts` | IPC client initialization |
|
| `src/ipc/manager.ts` | IPC client initialization |
|
||||||
|
|
||||||
### Types & Schema
|
### Types & Schema
|
||||||
|
|
||||||
| File | Role |
|
| File | Role |
|
||||||
|------|------|
|
| --------------------- | ------------------------------------------------ |
|
||||||
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
|
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
|
||||||
| `src/types/proxy.ts` | Request/response types |
|
| `src/types/proxy.ts` | Request/response types |
|
||||||
|
|
||||||
@@ -124,6 +128,7 @@ interface ProxyConfig {
|
|||||||
```
|
```
|
||||||
|
|
||||||
## Key Observations
|
## Key Observations
|
||||||
|
|
||||||
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
|
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
|
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
|
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
|
||||||
@@ -131,6 +136,7 @@ interface ProxyConfig {
|
|||||||
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
|
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
|
||||||
|
|
||||||
## Recommended Architecture Changes
|
## Recommended Architecture Changes
|
||||||
|
|
||||||
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
|
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
|
||||||
2. Add IPC endpoints for:
|
2. Add IPC endpoints for:
|
||||||
- `proxy.getTrafficLogs()`
|
- `proxy.getTrafficLogs()`
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
{
|
|
||||||
"branches": ["main"],
|
|
||||||
"plugins": [
|
|
||||||
["@semantic-release/commit-analyzer", {
|
|
||||||
"preset": "conventionalcommits",
|
|
||||||
"releaseRules": [
|
|
||||||
{ "type": "feat", "release": "minor" },
|
|
||||||
{ "type": "fix", "release": "patch" },
|
|
||||||
{ "type": "perf", "release": "patch" },
|
|
||||||
{ "type": "refactor", "release": "patch" },
|
|
||||||
{ "type": "docs", "release": false },
|
|
||||||
{ "type": "style", "release": false },
|
|
||||||
{ "type": "chore", "release": false },
|
|
||||||
{ "type": "ci", "release": false },
|
|
||||||
{ "type": "test", "release": false },
|
|
||||||
{ "breaking": true, "release": "major" }
|
|
||||||
]
|
|
||||||
}],
|
|
||||||
["@semantic-release/release-notes-generator", {
|
|
||||||
"preset": "conventionalcommits",
|
|
||||||
"presetConfig": {
|
|
||||||
"types": [
|
|
||||||
{ "type": "feat", "section": "🚀 Features" },
|
|
||||||
{ "type": "fix", "section": "🐛 Bug Fixes" },
|
|
||||||
{ "type": "perf", "section": "⚡ Performance" },
|
|
||||||
{ "type": "refactor", "section": "♻️ Refactors" },
|
|
||||||
{ "type": "docs", "section": "📚 Documentation", "hidden": true },
|
|
||||||
{ "type": "chore", "section": "🧰 Maintenance", "hidden": true },
|
|
||||||
{ "type": "ci", "section": "🔧 CI/CD", "hidden": true }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
["@semantic-release/changelog", {
|
|
||||||
"changelogFile": "CHANGELOG.md"
|
|
||||||
}],
|
|
||||||
["@semantic-release/npm", {
|
|
||||||
"npmPublish": false
|
|
||||||
}],
|
|
||||||
["@semantic-release/git", {
|
|
||||||
"assets": ["package.json", "pnpm-lock.yaml", "CHANGELOG.md"],
|
|
||||||
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
|
|
||||||
}],
|
|
||||||
["@semantic-release/github", {
|
|
||||||
"successComment": false,
|
|
||||||
"failTitle": false
|
|
||||||
}]
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ This document is a user-level guide for AI assistants working in this repository
|
|||||||
|
|
||||||
- **Runtime**: Node.js (Electron environment)
|
- **Runtime**: Node.js (Electron environment)
|
||||||
- **Node**: Recommended Node.js 20+
|
- **Node**: Recommended Node.js 20+
|
||||||
- **Package manager**: `npm` (this project includes `package-lock.json`; use npm only)
|
- **Package manager**: `pnpm` (this project includes `pnpm-lock.yaml`; use pnpm only)
|
||||||
- **Build tools**: Electron Forge + Vite
|
- **Build tools**: Electron Forge + Vite
|
||||||
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
|
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
|
||||||
|
|
||||||
@@ -75,32 +75,32 @@ This document is a user-level guide for AI assistants working in this repository
|
|||||||
|
|
||||||
## 📦 Common Scripts
|
## 📦 Common Scripts
|
||||||
|
|
||||||
Use `npm` for all commands:
|
Use `pnpm` for all commands:
|
||||||
|
|
||||||
- **Development (Dev)**:
|
- **Development (Dev)**:
|
||||||
- `npm start` - Start Electron dev environment (Electron Forge)
|
- `pnpm start` - Start Electron dev environment (Electron Forge)
|
||||||
- `npm run lint` - Run ESLint checks
|
- `pnpm run lint` - Run ESLint checks
|
||||||
- `npm run format` - Run Prettier check
|
- `pnpm run format` - Run Prettier check
|
||||||
- `npm run format:write` - Auto-format with Prettier
|
- `pnpm run format:write` - Auto-format with Prettier
|
||||||
- `npm run type-check` - Run TypeScript type check
|
- `pnpm run type-check` - Run TypeScript type check
|
||||||
|
|
||||||
- **Build**:
|
- **Build**:
|
||||||
- `npm run package` - Package app (application bundle only)
|
- `pnpm run package` - Package app (application bundle only)
|
||||||
- `npm run make` - Build and generate distributable installers
|
- `pnpm run make` - Build and generate distributable installers
|
||||||
- `npm run publish` - Publish app
|
- `pnpm run publish` - Publish app
|
||||||
|
|
||||||
- **Testing**:
|
- **Testing**:
|
||||||
- `npm test` - Run Vitest tests
|
- `pnpm test` - Run Vitest tests
|
||||||
- `npm run test:watch` - Run Vitest in watch mode
|
- `pnpm run test:watch` - Run Vitest in watch mode
|
||||||
- `npm run test:unit` - Same as above for unit-focused runs
|
- `pnpm run test:unit` - Same as above for unit-focused runs
|
||||||
- `npm run test:e2e` - Run Playwright E2E tests
|
- `pnpm run test:e2e` - Run Playwright E2E tests
|
||||||
- `npm run test:all` - Run all tests
|
- `pnpm run test:all` - Run all tests
|
||||||
|
|
||||||
### Running a Single Test
|
### Running a Single Test
|
||||||
|
|
||||||
- Unit test: `npm run test:unit path/to/test.test.ts`
|
- Unit test: `pnpm run test:unit path/to/test.test.ts`
|
||||||
- E2E test: `npm run test:e2e path/to/test.spec.ts`
|
- E2E test: `pnpm run test:e2e path/to/test.spec.ts`
|
||||||
- Type check: `npm run type-check`
|
- Type check: `pnpm run type-check`
|
||||||
|
|
||||||
## 🧪 Development Notes
|
## 🧪 Development Notes
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
|||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
> Before commit, run `npm run lint` and `npm run format`.
|
> Before commit, run `pnpm run lint` and `pnpm run format`.
|
||||||
|
|
||||||
## 📝 Terminal Output and References
|
## 📝 Terminal Output and References
|
||||||
|
|
||||||
@@ -330,7 +330,7 @@ Summary / output:
|
|||||||
|
|
||||||
Packages and scripts:
|
Packages and scripts:
|
||||||
|
|
||||||
- `npm install` (or `npm i`)
|
- `pnpm install` (or `pnpm i`)
|
||||||
|
|
||||||
Shell:
|
Shell:
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,55 @@
|
|||||||
|
<a name="readme-top"></a>
|
||||||
|
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [0.15.3](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.2...v0.15.3) (2026-05-02)
|
||||||
|
|
||||||
|
### 👷 Build System
|
||||||
|
|
||||||
|
* allow fs-xattr and appdmg to build in pnpm v10 ([1c26a9e](https://github.com/evandrodevbr/GeminiNexus/commit/1c26a9e542f3fa5a05b370b8a332ffe5a727ea92))
|
||||||
|
|
||||||
|
## [0.15.2](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.1...v0.15.2) (2026-05-02)
|
||||||
|
|
||||||
|
### 👷 Build System
|
||||||
|
|
||||||
|
* allow macos-alias and other native modules to build in pnpm v10 ([2e3a65b](https://github.com/evandrodevbr/GeminiNexus/commit/2e3a65b76b91012638c422f6223b79399775c635))
|
||||||
|
|
||||||
|
## [0.15.1](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.0...v0.15.1) (2026-05-02)
|
||||||
|
|
||||||
|
### ✅ Tests
|
||||||
|
|
||||||
|
* add universal electron mock to fix headless CI test runs ([1d35801](https://github.com/evandrodevbr/GeminiNexus/commit/1d35801980e3e54cec1d3e52ed75850eb7eec350))
|
||||||
|
* fix test environments failing due to missing IS_DEV_ENVIRONMENT mock export ([269ad18](https://github.com/evandrodevbr/GeminiNexus/commit/269ad18ed6c09585e881d0d916558b1bcc7ad940))
|
||||||
|
* update proxy-advanced-handler tests to reflect optional service registration ([f4daaa8](https://github.com/evandrodevbr/GeminiNexus/commit/f4daaa8ca13bc75c7e5090905176c06b61ecfedc))
|
||||||
|
|
||||||
|
### 👷 Build System
|
||||||
|
|
||||||
|
* replace MakerSquirrel with MakerZIP for Windows ([27223f5](https://github.com/evandrodevbr/GeminiNexus/commit/27223f587d7facf25beef77aa6984cd248f2703d))
|
||||||
|
|
||||||
|
## [0.15.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.14.0...v0.15.0) (2026-05-02)
|
||||||
|
|
||||||
|
### ✨ Features
|
||||||
|
|
||||||
|
* bump version to 0.14.0 ([a905877](https://github.com/evandrodevbr/GeminiNexus/commit/a905877c76869a9b31b5b079d7afd86e4c905b59))
|
||||||
|
* **core:** isolate dev and production environments ([5c44a62](https://github.com/evandrodevbr/GeminiNexus/commit/5c44a622a8b4fa81246d4ca77073be50b4ddc1c9))
|
||||||
|
* **proxy:** add OpenCode IDE config and fix service availability ([713c449](https://github.com/evandrodevbr/GeminiNexus/commit/713c4497c623b0519847ed2afcc7da04997dae55))
|
||||||
|
* **proxy:** enrich traffic logs with model and token metadata ([641c739](https://github.com/evandrodevbr/GeminiNexus/commit/641c739b64150d1df77fd1a81a028b8cb4a4884d))
|
||||||
|
* **ui:** replace sidebar with compact top header navigation ([7d2977b](https://github.com/evandrodevbr/GeminiNexus/commit/7d2977b665a1a2fb0d5d8844a8e519471cdef8d8))
|
||||||
|
* **usage:** add cost reporting with custom model pricing and OpenRouter ([bc69a2e](https://github.com/evandrodevbr/GeminiNexus/commit/bc69a2e8b52e5019c45faae12771af7e40d3f26b))
|
||||||
|
* **usage:** move Monitor to Usage tab with per-request token tracking ([99e458f](https://github.com/evandrodevbr/GeminiNexus/commit/99e458f69bd5564d27f94c72c719ba49efd72abe))
|
||||||
|
* **usage:** redesign Usage Analytics with accurate token averages ([20785a8](https://github.com/evandrodevbr/GeminiNexus/commit/20785a8414bb0e364085a4a64f6a958c178d1df8))
|
||||||
|
|
||||||
|
### 🐛 Bug Fixes
|
||||||
|
|
||||||
|
* **ci:** remove explicit pnpm version from workflows ([080bf30](https://github.com/evandrodevbr/GeminiNexus/commit/080bf30e38379db5cb3c8d124f76a39aef897ccc))
|
||||||
|
* **ci:** resolve release pipeline conflicts and clean repo structure ([71bf812](https://github.com/evandrodevbr/GeminiNexus/commit/71bf8122d1a3d5bd74e3450f61d24747b50ed9d7))
|
||||||
|
* **ci:** use Node 22 for semantic-release (requires ^22.14.0) ([268428a](https://github.com/evandrodevbr/GeminiNexus/commit/268428a1193fd0bfd0559472fd50b3293d9c8ccb))
|
||||||
|
* install notice showing in Chinese and not recognizing MSI install path ([0753bbd](https://github.com/evandrodevbr/GeminiNexus/commit/0753bbdce63cffcd40fa13643e1082a67335c9c4))
|
||||||
|
|
||||||
|
### ♻️ Code Refactoring
|
||||||
|
|
||||||
|
* **accounts:** decompose CloudAccountList into focused components ([bf5a42c](https://github.com/evandrodevbr/GeminiNexus/commit/bf5a42c03e706d19033ceff332ee3609afded7ed))
|
||||||
|
|
||||||
## [0.14.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.13.1...v0.14.0) (2026-04-29)
|
## [0.14.0](https://github.com/evandrodevbr/GeminiNexus/compare/v0.13.1...v0.14.0) (2026-04-29)
|
||||||
|
|
||||||
### 🚀 Features
|
### 🚀 Features
|
||||||
|
|||||||
+2
-2
@@ -64,7 +64,7 @@ npm start
|
|||||||
### Available Scripts
|
### Available Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
| ---------------------- | ------------------------------------------------------- |
|
||||||
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
|
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
|
||||||
| `npm run lint` | Run ESLint |
|
| `npm run lint` | Run ESLint |
|
||||||
| `npm run format` | Check formatting with Prettier |
|
| `npm run format` | Check formatting with Prettier |
|
||||||
@@ -136,7 +136,7 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Commits
|
|||||||
### Types
|
### Types
|
||||||
|
|
||||||
| Type | Description | Release |
|
| Type | Description | Release |
|
||||||
|------|-------------|---------|
|
| ---------- | ----------------------------------- | ---------- |
|
||||||
| `feat` | A new feature | **minor** |
|
| `feat` | A new feature | **minor** |
|
||||||
| `fix` | A bug fix | **patch** |
|
| `fix` | A bug fix | **patch** |
|
||||||
| `perf` | Performance improvement | **patch** |
|
| `perf` | Performance improvement | **patch** |
|
||||||
|
|||||||
+1
-1
@@ -197,7 +197,7 @@ Model: gemini-3-flash
|
|||||||
## 🛠️ Stack Tecnológico
|
## 🛠️ Stack Tecnológico
|
||||||
|
|
||||||
| Categoría | Tecnologías |
|
| Categoría | Tecnologías |
|
||||||
|-----------|------------|
|
| ----------------- | ---------------------------------------------- |
|
||||||
| **Core** | Electron, React 19, TypeScript |
|
| **Core** | Electron, React 19, TypeScript |
|
||||||
| **Build** | Vite, Electron Forge |
|
| **Build** | Vite, Electron Forge |
|
||||||
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||||
|
|||||||
+1
-2
@@ -159,7 +159,6 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
|||||||
|
|
||||||
## 📸 Screenshots
|
## 📸 Screenshots
|
||||||
|
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="docs/assets/main.png" alt="Main" width="80%" />
|
<img src="docs/assets/main.png" alt="Main" width="80%" />
|
||||||
</p>
|
</p>
|
||||||
@@ -224,7 +223,7 @@ Model: gemini-3-flash (ou qualquer modelo mapeado)
|
|||||||
## 🛠️ Stack Tecnológica
|
## 🛠️ Stack Tecnológica
|
||||||
|
|
||||||
| Categoria | Tecnologias |
|
| Categoria | Tecnologias |
|
||||||
|-----------|------------|
|
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||||
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
||||||
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
||||||
|
|||||||
+1
-1
@@ -195,7 +195,7 @@ Model: gemini-3-flash
|
|||||||
## 🛠️ 技术栈
|
## 🛠️ 技术栈
|
||||||
|
|
||||||
| 分类 | 技术 |
|
| 分类 | 技术 |
|
||||||
|------|------|
|
| ---------- | --------------------------------------- |
|
||||||
| **核心** | Electron, React 19, TypeScript |
|
| **核心** | Electron, React 19, TypeScript |
|
||||||
| **构建** | Vite, Electron Forge |
|
| **构建** | Vite, Electron Forge |
|
||||||
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||||
|
|||||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+16
-4
@@ -3,7 +3,7 @@
|
|||||||
## 1. Nome e Descrição
|
## 1. Nome e Descrição
|
||||||
|
|
||||||
| Campo | Valor |
|
| Campo | Valor |
|
||||||
|-------|-------|
|
| ------------------- | -------------------------------------------------------------------- |
|
||||||
| **Nome** | Gemini Nexus |
|
| **Nome** | Gemini Nexus |
|
||||||
| **Product Name** | Gemini Nexus |
|
| **Product Name** | Gemini Nexus |
|
||||||
| **Package Name** | gemini-nexus |
|
| **Package Name** | gemini-nexus |
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
|
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
|
||||||
|
|
||||||
### Funcionalidades Principais
|
### Funcionalidades Principais
|
||||||
|
|
||||||
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
|
- **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
|
- **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
|
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
|
||||||
@@ -27,6 +28,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
|||||||
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
|
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
|
||||||
|
|
||||||
### Público-Alvo
|
### Público-Alvo
|
||||||
|
|
||||||
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
|
- 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
|
- Power users que precisam alternar entre múltiplas contas de IA
|
||||||
- Usuários que precisam de um proxy API local confiável
|
- Usuários que precisam de um proxy API local confiável
|
||||||
@@ -46,28 +48,32 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
|||||||
## 4. Estilo Visual Atual
|
## 4. Estilo Visual Atual
|
||||||
|
|
||||||
### Tema
|
### Tema
|
||||||
|
|
||||||
- Dual theme (claro/escuro) com detecção automática do sistema
|
- Dual theme (claro/escuro) com detecção automática do sistema
|
||||||
- Extremamente minimalista e quase monocromático
|
- Extremamente minimalista e quase monocromático
|
||||||
|
|
||||||
### Paleta de Cores (Tailwind OKLCH)
|
### Paleta de Cores (Tailwind OKLCH)
|
||||||
|
|
||||||
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
||||||
|------|-----------|------------|---------|-------------|----------|
|
| ---------- | ------------------------------ | --------------------- | ------- | --------------- | -------- |
|
||||||
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
|
| **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 | — |
|
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
|
||||||
|
|
||||||
### Cores Funcionais
|
### Cores Funcionais
|
||||||
|
|
||||||
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
|
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
|
||||||
- **Amber/Yellow** — quota média, alertas
|
- **Amber/Yellow** — quota média, alertas
|
||||||
- **Rose/Red** — quota baixa, erro, stopped
|
- **Rose/Red** — quota baixa, erro, stopped
|
||||||
- **Blue** — AI credits, informações premium
|
- **Blue** — AI credits, informações premium
|
||||||
|
|
||||||
### Tipografia
|
### Tipografia
|
||||||
|
|
||||||
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
|
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
|
||||||
- **Geist Mono** (monospace) — dados técnicos, números de quota
|
- **Geist Mono** (monospace) — dados técnicos, números de quota
|
||||||
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
|
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
|
||||||
|
|
||||||
### Características de UI
|
### Características de UI
|
||||||
|
|
||||||
- Bordas arredondadas (`0.5rem` / 8px)
|
- Bordas arredondadas (`0.5rem` / 8px)
|
||||||
- Sombras sutis
|
- Sombras sutis
|
||||||
- Cards com hover states suaves
|
- Cards com hover states suaves
|
||||||
@@ -78,7 +84,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
|||||||
## 5. Assets Visuais Existentes
|
## 5. Assets Visuais Existentes
|
||||||
|
|
||||||
| Arquivo | Descrição |
|
| Arquivo | Descrição |
|
||||||
|---------|-----------|
|
| ---------------------------------------------------------------- | ---------------------------------------------------- |
|
||||||
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
||||||
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
||||||
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
||||||
@@ -97,25 +103,30 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
|||||||
## 6. Conceitos Visuais Sugeridos para a Logo
|
## 6. Conceitos Visuais Sugeridos para a Logo
|
||||||
|
|
||||||
### A. Nodo / Conexão (Nexus)
|
### A. Nodo / Conexão (Nexus)
|
||||||
|
|
||||||
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
|
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
|
||||||
- Círculo central com 2-3 linhas finas irradiando
|
- Círculo central com 2-3 linhas finas irradiando
|
||||||
- Dois círculos interligados (Gemini + Claude)
|
- Dois círculos interligados (Gemini + Claude)
|
||||||
|
|
||||||
### B. Infinito / Loop (Gemini + Proxy)
|
### B. Infinito / Loop (Gemini + Proxy)
|
||||||
|
|
||||||
- Símbolo de infinito (∞) modificado como circuito/loop de dados
|
- Símbolo de infinito (∞) modificado como circuito/loop de dados
|
||||||
- Referência à dualidade "Gemini" (gêmeos)
|
- Referência à dualidade "Gemini" (gêmeos)
|
||||||
- Curva contínua elegante com seta indicando fluxo
|
- Curva contínua elegante com seta indicando fluxo
|
||||||
|
|
||||||
### C. Cubo / Hexágono (Nexus / Hub)
|
### C. Cubo / Hexágono (Nexus / Hub)
|
||||||
|
|
||||||
- Hexágono ou cubo isométrico minimalista
|
- Hexágono ou cubo isométrico minimalista
|
||||||
- Linhas finas, perspectiva suave
|
- Linhas finas, perspectiva suave
|
||||||
- Face "aberta" ou conectada
|
- Face "aberta" ou conectada
|
||||||
|
|
||||||
### D. Monograma "GN"
|
### D. Monograma "GN"
|
||||||
|
|
||||||
- Letras "G" e "N" abstraídas como nodo de rede
|
- Letras "G" e "N" abstraídas como nodo de rede
|
||||||
- Estilo geométrico, similar a Tomorrow ou Geist
|
- Estilo geométrico, similar a Tomorrow ou Geist
|
||||||
|
|
||||||
### E. Proxy / Pipe (Fluxo de Dados)
|
### E. Proxy / Pipe (Fluxo de Dados)
|
||||||
|
|
||||||
- Duas barras paralelas com seta passando entre elas
|
- Duas barras paralelas com seta passando entre elas
|
||||||
- "Pipe" minimalista — conduz tráfego entre APIs
|
- "Pipe" minimalista — conduz tráfego entre APIs
|
||||||
|
|
||||||
@@ -124,7 +135,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
|||||||
## 7. Diretrizes para a Nova Logo
|
## 7. Diretrizes para a Nova Logo
|
||||||
|
|
||||||
| Elemento | Especificação |
|
| Elemento | Especificação |
|
||||||
|----------|---------------|
|
| ---------------------------- | ------------------------------------------------------------------------- |
|
||||||
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
||||||
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
||||||
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
||||||
@@ -140,6 +151,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
|||||||
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
|
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:
|
A logo deve transmitir:
|
||||||
|
|
||||||
- **Tecnologia** e **precisão**
|
- **Tecnologia** e **precisão**
|
||||||
- **Conectividade** e **hub central**
|
- **Conectividade** e **hub central**
|
||||||
- **Profissionalismo** e **confiança**
|
- **Profissionalismo** e **confiança**
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
|
|
||||||
// Extensões permitidas
|
|
||||||
const allowedExtensions = /\.(ts|tsx)$/;
|
|
||||||
|
|
||||||
function walkAndFix(dir) {
|
|
||||||
const files = fs.readdirSync(dir);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (['node_modules', '.git', 'dist', 'out'].includes(file)) continue;
|
|
||||||
|
|
||||||
const fullPath = path.join(dir, file);
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
walkAndFix(fullPath);
|
|
||||||
} else if (allowedExtensions.test(file)) {
|
|
||||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
|
||||||
const original = content;
|
|
||||||
|
|
||||||
// Fix specific known broken identifiers
|
|
||||||
content = content.replace(/closeGemini Nexus/g, 'closeGeminiNexus');
|
|
||||||
content = content.replace(/startGemini Nexus/g, 'startGeminiNexus');
|
|
||||||
content = content.replace(/Gemini NexusVersion/g, 'GeminiNexusVersion');
|
|
||||||
content = content.replace(/Gemini NexusDb/g, 'GeminiNexusDb');
|
|
||||||
content = content.replace(/Gemini NexusStorage/g, 'GeminiNexusStorage');
|
|
||||||
content = content.replace(/Gemini NexusExecutable/g, 'GeminiNexusExecutable');
|
|
||||||
content = content.replace(/Gemini NexusCoreFeatures/g, 'GeminiNexusCoreFeatures');
|
|
||||||
content = content.replace(/getGemini Nexus/g, 'getGeminiNexus');
|
|
||||||
|
|
||||||
if (content !== original) {
|
|
||||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
||||||
console.log(`Fixed TS errors in: ${fullPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
walkAndFix(projectRoot);
|
|
||||||
console.log('Fix complete.');
|
|
||||||
Generated
-27
@@ -1,27 +0,0 @@
|
|||||||
{
|
|
||||||
"nodes": {
|
|
||||||
"nixpkgs": {
|
|
||||||
"locked": {
|
|
||||||
"lastModified": 1774106199,
|
|
||||||
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
|
|
||||||
"owner": "NixOS",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
|
|
||||||
"type": "github"
|
|
||||||
},
|
|
||||||
"original": {
|
|
||||||
"owner": "NixOS",
|
|
||||||
"ref": "nixos-unstable",
|
|
||||||
"repo": "nixpkgs",
|
|
||||||
"type": "github"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": {
|
|
||||||
"inputs": {
|
|
||||||
"nixpkgs": "nixpkgs"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"root": "root",
|
|
||||||
"version": 7
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
{
|
|
||||||
description = "Antigravity Manager";
|
|
||||||
|
|
||||||
inputs = {
|
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
|
||||||
};
|
|
||||||
|
|
||||||
outputs = {
|
|
||||||
self,
|
|
||||||
nixpkgs,
|
|
||||||
}: let
|
|
||||||
supportedSystems = ["x86_64-linux" "aarch64-linux"];
|
|
||||||
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
|
|
||||||
|
|
||||||
version = "0.10.0";
|
|
||||||
|
|
||||||
sources = {
|
|
||||||
"x86_64-linux" = {
|
|
||||||
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_amd64.deb";
|
|
||||||
sha256 = "5fc08b4a9308c71fc00e44b95d4fb3178182aea8821708fa51fdf2bf5dbbb856";
|
|
||||||
};
|
|
||||||
"aarch64-linux" = {
|
|
||||||
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_arm64.deb";
|
|
||||||
sha256 = "666c9659efa66bb6be6a8e7fb0c384ceb799a17245eab374bf9b3d7901c5c4cb";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
mkPackage = pkgs: let
|
|
||||||
system = pkgs.stdenv.hostPlatform.system;
|
|
||||||
source = sources.${system} or (throw "Unsupported system: ${system}");
|
|
||||||
in
|
|
||||||
pkgs.stdenv.mkDerivation {
|
|
||||||
pname = "antigravity-manager";
|
|
||||||
inherit version;
|
|
||||||
|
|
||||||
src = pkgs.fetchurl {
|
|
||||||
inherit (source) url sha256;
|
|
||||||
};
|
|
||||||
|
|
||||||
nativeBuildInputs = with pkgs; [
|
|
||||||
autoPatchelfHook
|
|
||||||
makeWrapper
|
|
||||||
zstd
|
|
||||||
];
|
|
||||||
|
|
||||||
buildInputs = with pkgs; [
|
|
||||||
alsa-lib
|
|
||||||
at-spi2-atk
|
|
||||||
atk
|
|
||||||
cups
|
|
||||||
dbus
|
|
||||||
expat
|
|
||||||
glib
|
|
||||||
gtk3
|
|
||||||
libsecret
|
|
||||||
libdrm
|
|
||||||
libxkbcommon
|
|
||||||
mesa
|
|
||||||
nspr
|
|
||||||
nss
|
|
||||||
pango
|
|
||||||
systemd
|
|
||||||
libx11
|
|
||||||
libxcomposite
|
|
||||||
libxdamage
|
|
||||||
libxext
|
|
||||||
libxfixes
|
|
||||||
libxrandr
|
|
||||||
libxcb
|
|
||||||
];
|
|
||||||
|
|
||||||
unpackPhase = ''
|
|
||||||
ar x $src
|
|
||||||
tar xf data.tar.* --no-same-permissions
|
|
||||||
'';
|
|
||||||
|
|
||||||
installPhase = ''
|
|
||||||
mkdir -p $out/bin $out/lib $out/share
|
|
||||||
cp -r usr/lib/antigravity-manager $out/lib/
|
|
||||||
cp -r usr/share/* $out/share/
|
|
||||||
|
|
||||||
ln -s $out/lib/antigravity-manager/antigravity-manager $out/bin/antigravity-manager
|
|
||||||
'';
|
|
||||||
|
|
||||||
postFixup = ''
|
|
||||||
# Wrap the executable so it finds Wayland/X11 and libsecret
|
|
||||||
wrapProgram $out/bin/antigravity-manager \
|
|
||||||
--add-flags "--enable-features=WaylandWindowDecorations" \
|
|
||||||
--add-flags "--ozone-platform-hint=auto" \
|
|
||||||
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [pkgs.libsecret]}
|
|
||||||
|
|
||||||
if [ -e $out/share/applications/antigravity-manager.desktop ]; then
|
|
||||||
substituteInPlace $out/share/applications/antigravity-manager.desktop \
|
|
||||||
--replace "/usr/lib/antigravity-manager/antigravity-manager" "antigravity-manager"
|
|
||||||
fi
|
|
||||||
'';
|
|
||||||
|
|
||||||
meta = with pkgs.lib; {
|
|
||||||
description = "Professional multi-account manager for Google Gemini & Claude AI";
|
|
||||||
homepage = "https://github.com/Draculabo/AntigravityManager";
|
|
||||||
license = licenses.cc-by-nc-sa-40;
|
|
||||||
maintainers = [];
|
|
||||||
platforms = ["x86_64-linux" "aarch64-linux"];
|
|
||||||
mainProgram = "antigravity-manager";
|
|
||||||
};
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
overlays.default = final: prev: {
|
|
||||||
antigravity-manager = mkPackage final;
|
|
||||||
};
|
|
||||||
|
|
||||||
packages = forAllSystems (system: let
|
|
||||||
pkgs = import nixpkgs {
|
|
||||||
inherit system;
|
|
||||||
config.allowUnfree = true;
|
|
||||||
};
|
|
||||||
in {
|
|
||||||
default = mkPackage pkgs;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+1
-6
@@ -10,7 +10,6 @@ import type {
|
|||||||
import { MakerDeb } from '@electron-forge/maker-deb';
|
import { MakerDeb } from '@electron-forge/maker-deb';
|
||||||
import { MakerDMG } from '@electron-forge/maker-dmg';
|
import { MakerDMG } from '@electron-forge/maker-dmg';
|
||||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
|
||||||
import { MakerWix } from '@electron-forge/maker-wix';
|
import { MakerWix } from '@electron-forge/maker-wix';
|
||||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||||
@@ -441,10 +440,6 @@ const config: ForgeConfig = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
makers: [
|
makers: [
|
||||||
new MakerSquirrel({
|
|
||||||
setupIcon: 'images/icon.ico',
|
|
||||||
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
|
||||||
}),
|
|
||||||
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
|
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
|
||||||
? [
|
? [
|
||||||
new MakerWix({
|
new MakerWix({
|
||||||
@@ -463,7 +458,7 @@ const config: ForgeConfig = {
|
|||||||
},
|
},
|
||||||
['darwin'],
|
['darwin'],
|
||||||
),
|
),
|
||||||
new MakerZIP({}, ['darwin']),
|
new MakerZIP({}, ['darwin', 'win32']),
|
||||||
appImageMaker,
|
appImageMaker,
|
||||||
new MakerRpm({}),
|
new MakerRpm({}),
|
||||||
new MakerDeb({}),
|
new MakerDeb({}),
|
||||||
|
|||||||
+26
-3
@@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "gemini-nexus",
|
"name": "gemini-nexus",
|
||||||
"productName": "Gemini Nexus",
|
"productName": "Gemini Nexus",
|
||||||
"version": "0.14.0",
|
"version": "0.15.3",
|
||||||
"description": "Gemini Nexus - Electron App",
|
"description": "Gemini Nexus - Electron App",
|
||||||
"main": ".vite/build/main.js",
|
"main": ".vite/build/main.js",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"packageManager": "[email protected]",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
|
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||||
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||||
@@ -31,7 +32,6 @@
|
|||||||
"@electron-forge/maker-deb": "^7.11.1",
|
"@electron-forge/maker-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-wix": "^7.9.0",
|
"@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",
|
||||||
@@ -170,9 +170,32 @@
|
|||||||
"@nestjs/core",
|
"@nestjs/core",
|
||||||
"@sentry/cli",
|
"@sentry/cli",
|
||||||
"@swc/core",
|
"@swc/core",
|
||||||
"protobufjs"
|
"protobufjs",
|
||||||
|
"macos-alias",
|
||||||
|
"better-sqlite3",
|
||||||
|
"keytar",
|
||||||
|
"electron",
|
||||||
|
"electron-winstaller",
|
||||||
|
"esbuild",
|
||||||
|
"fs-xattr",
|
||||||
|
"appdmg"
|
||||||
],
|
],
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
"onlyBuiltDependencies": [
|
||||||
|
"@bitdisaster/exe-icon-extractor",
|
||||||
|
"@nestjs/core",
|
||||||
|
"@sentry/cli",
|
||||||
|
"@swc/core",
|
||||||
|
"protobufjs",
|
||||||
|
"macos-alias",
|
||||||
|
"better-sqlite3",
|
||||||
|
"keytar",
|
||||||
|
"electron",
|
||||||
|
"electron-winstaller",
|
||||||
|
"esbuild",
|
||||||
|
"fs-xattr",
|
||||||
|
"appdmg"
|
||||||
|
],
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"node-abi": "^4.28.0",
|
"node-abi": "^4.28.0",
|
||||||
"fastify": "^5.8.5",
|
"fastify": "^5.8.5",
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
let file = fs.readFileSync('src/routes/proxy.tsx', 'utf8');
|
|
||||||
|
|
||||||
// The Proxy layout also uses heavy cards
|
|
||||||
file = file.replace(/<Card>/g, '<Card className="bg-background border border-white/5 shadow-none">');
|
|
||||||
|
|
||||||
fs.writeFileSync('src/routes/proxy.tsx', file);
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
const fs = require('fs');
|
|
||||||
let file = fs.readFileSync('src/routes/settings.tsx', 'utf8');
|
|
||||||
|
|
||||||
// The settings page uses standard cards that look bulky in minimal dark mode
|
|
||||||
file = file.replace(/<div className="flex flex-col gap-6">/g, '<div className="flex flex-col gap-2">');
|
|
||||||
file = file.replace(/<Card>/g, '<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">');
|
|
||||||
file = file.replace(/<CardHeader>/g, '<CardHeader className="px-0 pt-4">');
|
|
||||||
file = file.replace(/<CardContent>/g, '<CardContent className="px-0">');
|
|
||||||
|
|
||||||
fs.writeFileSync('src/routes/settings.tsx', file);
|
|
||||||
|
|
||||||
Generated
+7
-124
@@ -227,9 +227,6 @@ importers:
|
|||||||
'@electron-forge/maker-rpm':
|
'@electron-forge/maker-rpm':
|
||||||
specifier: ^7.11.1
|
specifier: ^7.11.1
|
||||||
version: 7.11.1
|
version: 7.11.1
|
||||||
'@electron-forge/maker-squirrel':
|
|
||||||
specifier: ^7.11.1
|
|
||||||
version: 7.11.1
|
|
||||||
'@electron-forge/maker-wix':
|
'@electron-forge/maker-wix':
|
||||||
specifier: ^7.9.0
|
specifier: ^7.9.0
|
||||||
version: 7.11.1
|
version: 7.11.1
|
||||||
@@ -397,7 +394,7 @@ importers:
|
|||||||
version: 25.0.3([email protected])
|
version: 25.0.3([email protected])
|
||||||
semantic-release-config-gitmoji:
|
semantic-release-config-gitmoji:
|
||||||
specifier: ^1.5.3
|
specifier: ^1.5.3
|
||||||
version: 1.5.3(@octokit/core@7.0.6)([email protected])([email protected]([email protected]))
|
version: 1.5.3(@octokit/core@4.2.4)([email protected])([email protected]([email protected]))
|
||||||
sharp:
|
sharp:
|
||||||
specifier: ^0.34.5
|
specifier: ^0.34.5
|
||||||
version: 0.34.5
|
version: 0.34.5
|
||||||
@@ -663,10 +660,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-iEfJPRQQyaTqk2EbUfZgulChNWvxGXeYUH0xBX/r5cj1pL4vcJXt3jLMQBVn3mk/0Ytv9UWRs8R/XuNWX6sf2w==}
|
resolution: {integrity: sha512-iEfJPRQQyaTqk2EbUfZgulChNWvxGXeYUH0xBX/r5cj1pL4vcJXt3jLMQBVn3mk/0Ytv9UWRs8R/XuNWX6sf2w==}
|
||||||
engines: {node: '>= 16.4.0'}
|
engines: {node: '>= 16.4.0'}
|
||||||
|
|
||||||
'@electron-forge/[email protected]':
|
|
||||||
resolution: {integrity: sha512-oSg7fgad6l+X0DjtRkSpMzB0AjzyDO4mb2gzM4kTodkP1ADeiMi08bxy0ZeCESqLm5+fG72cAPmEr3BAPvI1yw==}
|
|
||||||
engines: {node: '>= 16.4.0'}
|
|
||||||
|
|
||||||
'@electron-forge/[email protected]':
|
'@electron-forge/[email protected]':
|
||||||
resolution: {integrity: sha512-yKsnfPQVBrjyjWndmSB0rqQSXAiIukwz5fuVyoCTnYpfJQojrEEMUN/eXSHA08d+rOPbB/hkMAR+QJcjJ8vO2Q==}
|
resolution: {integrity: sha512-yKsnfPQVBrjyjWndmSB0rqQSXAiIukwz5fuVyoCTnYpfJQojrEEMUN/eXSHA08d+rOPbB/hkMAR+QJcjJ8vO2Q==}
|
||||||
engines: {node: '>= 16.4.0'}
|
engines: {node: '>= 16.4.0'}
|
||||||
@@ -1160,183 +1153,155 @@ packages:
|
|||||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@img/[email protected]':
|
'@img/[email protected]':
|
||||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||||
@@ -2710,42 +2675,36 @@ packages:
|
|||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
|
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
|
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
|
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
|
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
|
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
|
||||||
engines: {node: ^20.19.0 || >=22.12.0}
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rolldown/[email protected]':
|
'@rolldown/[email protected]':
|
||||||
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
|
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
|
||||||
@@ -2836,79 +2795,66 @@ packages:
|
|||||||
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
|
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
|
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
|
||||||
cpu: [arm]
|
cpu: [arm]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
|
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
|
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
|
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
|
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
|
||||||
cpu: [loong64]
|
cpu: [loong64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
|
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
|
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
|
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
|
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
|
||||||
cpu: [riscv64]
|
cpu: [riscv64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
|
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
|
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
|
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@rollup/[email protected]':
|
'@rollup/[email protected]':
|
||||||
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
|
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
|
||||||
@@ -3180,42 +3126,36 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==}
|
resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==}
|
resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [ppc64]
|
cpu: [ppc64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==}
|
resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [s390x]
|
cpu: [s390x]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==}
|
resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==}
|
resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@swc/[email protected]':
|
'@swc/[email protected]':
|
||||||
resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==}
|
resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==}
|
||||||
@@ -3292,28 +3232,24 @@ packages:
|
|||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@tailwindcss/[email protected]':
|
'@tailwindcss/[email protected]':
|
||||||
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
|
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@tailwindcss/[email protected]':
|
'@tailwindcss/[email protected]':
|
||||||
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
|
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
'@tailwindcss/[email protected]':
|
'@tailwindcss/[email protected]':
|
||||||
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
|
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
'@tailwindcss/[email protected]':
|
'@tailwindcss/[email protected]':
|
||||||
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
|
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
|
||||||
@@ -4961,10 +4897,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==}
|
resolution: {integrity: sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
|
||||||
engines: {node: '>=8.0.0'}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-EYj1cm1nZoVHmIIx3o0aKt784lxdEpJnXbEnyypklUCnglqSb7ni+1xi1Vp/gtrGS/mzIxnWBT+x5fIfuDjhvA==}
|
resolution: {integrity: sha512-EYj1cm1nZoVHmIIx3o0aKt784lxdEpJnXbEnyypklUCnglqSb7ni+1xi1Vp/gtrGS/mzIxnWBT+x5fIfuDjhvA==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -5433,10 +5365,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||||
engines: {node: '>=14.14'}
|
engines: {node: '>=14.14'}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
|
||||||
engines: {node: '>=6 <7 || >=8'}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||||
engines: {node: '>=6 <7 || >=8'}
|
engines: {node: '>=6 <7 || >=8'}
|
||||||
@@ -6296,28 +6224,24 @@ packages:
|
|||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [arm64]
|
cpu: [arm64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [glibc]
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||||
engines: {node: '>= 12.0.0'}
|
engines: {node: '>= 12.0.0'}
|
||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [linux]
|
os: [linux]
|
||||||
libc: [musl]
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||||
@@ -8107,10 +8031,6 @@ packages:
|
|||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
|
|
||||||
engines: {node: '>=6.0.0'}
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==}
|
resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==}
|
||||||
engines: {node: '>=14.16'}
|
engines: {node: '>=14.16'}
|
||||||
@@ -8874,10 +8794,10 @@ snapshots:
|
|||||||
|
|
||||||
'@acemir/[email protected]': {}
|
'@acemir/[email protected]': {}
|
||||||
|
|
||||||
'@achingbrain/[email protected](@octokit/core@7.0.6)([email protected]([email protected]))':
|
'@achingbrain/[email protected](@octokit/core@4.2.4)([email protected]([email protected]))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/plugin-retry': 3.0.9
|
'@octokit/plugin-retry': 3.0.9
|
||||||
'@octokit/plugin-throttling': 3.7.0(@octokit/core@7.0.6)
|
'@octokit/plugin-throttling': 3.7.0(@octokit/core@4.2.4)
|
||||||
'@octokit/rest': 19.0.13
|
'@octokit/rest': 19.0.13
|
||||||
'@semantic-release/error': 3.0.0
|
'@semantic-release/error': 3.0.0
|
||||||
aggregate-error: 3.1.0
|
aggregate-error: 3.1.0
|
||||||
@@ -9269,17 +9189,6 @@ snapshots:
|
|||||||
- bluebird
|
- bluebird
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@electron-forge/[email protected]':
|
|
||||||
dependencies:
|
|
||||||
'@electron-forge/maker-base': 7.11.1
|
|
||||||
'@electron-forge/shared-types': 7.11.1
|
|
||||||
fs-extra: 10.1.0
|
|
||||||
optionalDependencies:
|
|
||||||
electron-winstaller: 5.4.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- bluebird
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@electron-forge/[email protected]':
|
'@electron-forge/[email protected]':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electron-forge/core-utils': 7.11.1
|
'@electron-forge/core-utils': 7.11.1
|
||||||
@@ -10637,9 +10546,9 @@ snapshots:
|
|||||||
'@octokit/types': 16.0.0
|
'@octokit/types': 16.0.0
|
||||||
bottleneck: 2.19.5
|
bottleneck: 2.19.5
|
||||||
|
|
||||||
'@octokit/[email protected](@octokit/core@7.0.6)':
|
'@octokit/[email protected](@octokit/core@4.2.4)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@octokit/core': 7.0.6
|
'@octokit/core': 4.2.4
|
||||||
'@octokit/types': 6.41.0
|
'@octokit/types': 6.41.0
|
||||||
bottleneck: 2.19.5
|
bottleneck: 2.19.5
|
||||||
|
|
||||||
@@ -14036,19 +13945,6 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
'@electron/asar': 3.4.1
|
|
||||||
debug: 4.4.3
|
|
||||||
fs-extra: 7.0.1
|
|
||||||
lodash: 4.18.1
|
|
||||||
temp: 0.9.4
|
|
||||||
optionalDependencies:
|
|
||||||
'@electron/windows-sign': 1.2.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@electron/windows-sign': 1.2.2
|
'@electron/windows-sign': 1.2.2
|
||||||
@@ -14730,13 +14626,6 @@ snapshots:
|
|||||||
jsonfile: 6.2.1
|
jsonfile: 6.2.1
|
||||||
universalify: 2.0.1
|
universalify: 2.0.1
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
graceful-fs: 4.2.11
|
|
||||||
jsonfile: 4.0.0
|
|
||||||
universalify: 0.1.2
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
@@ -16944,12 +16833,12 @@ snapshots:
|
|||||||
|
|
||||||
[email protected]: {}
|
[email protected]: {}
|
||||||
|
|
||||||
[email protected](@octokit/core@7.0.6)([email protected])([email protected]([email protected])):
|
[email protected](@octokit/core@4.2.4)([email protected])([email protected]([email protected])):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@gitmoji/commit-types': 1.1.5
|
'@gitmoji/commit-types': 1.1.5
|
||||||
'@semantic-release/changelog': 6.0.3([email protected]([email protected]))
|
'@semantic-release/changelog': 6.0.3([email protected]([email protected]))
|
||||||
'@semantic-release/git': 10.0.1([email protected]([email protected]))
|
'@semantic-release/git': 10.0.1([email protected]([email protected]))
|
||||||
'@semantic-release/github': '@achingbrain/[email protected](@octokit/core@7.0.6)([email protected]([email protected]))'
|
'@semantic-release/github': '@achingbrain/[email protected](@octokit/core@4.2.4)([email protected]([email protected]))'
|
||||||
'@semrel-extra/npm': 1.2.2
|
'@semrel-extra/npm': 1.2.2
|
||||||
'@types/semantic-release': 17.2.11
|
'@types/semantic-release': 17.2.11
|
||||||
conventional-changelog-gitmoji-config: 1.5.2([email protected])
|
conventional-changelog-gitmoji-config: 1.5.2([email protected])
|
||||||
@@ -17472,12 +17361,6 @@ snapshots:
|
|||||||
async-exit-hook: 2.0.1
|
async-exit-hook: 2.0.1
|
||||||
fs-extra: 10.1.0
|
fs-extra: 10.1.0
|
||||||
|
|
||||||
[email protected]:
|
|
||||||
dependencies:
|
|
||||||
mkdirp: 0.5.6
|
|
||||||
rimraf: 2.6.3
|
|
||||||
optional: true
|
|
||||||
|
|
||||||
[email protected]:
|
[email protected]:
|
||||||
dependencies:
|
dependencies:
|
||||||
is-stream: 3.0.0
|
is-stream: 3.0.0
|
||||||
|
|||||||
@@ -8,5 +8,8 @@ allowBuilds:
|
|||||||
electron-winstaller: true
|
electron-winstaller: true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
keytar: true
|
keytar: true
|
||||||
|
macos-alias: true
|
||||||
protobufjs: true
|
protobufjs: true
|
||||||
sharp: true
|
sharp: true
|
||||||
|
fs-xattr: true
|
||||||
|
appdmg: true
|
||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
* instead of Gitmoji (since the project history doesn't use emojis).
|
* instead of Gitmoji (since the project history doesn't use emojis).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
|
const REPO_URL = 'https://github.com/evandrodevbr/GeminiNexus';
|
||||||
const GITHUB_BASE_URL = 'https://github.com';
|
const GITHUB_BASE_URL = 'https://github.com';
|
||||||
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
|
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
|
||||||
|
|
||||||
@@ -244,7 +244,7 @@ const releaseConfig = {
|
|||||||
[
|
[
|
||||||
'@semantic-release/git',
|
'@semantic-release/git',
|
||||||
{
|
{
|
||||||
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
assets: ['CHANGELOG.md', 'package.json', 'pnpm-lock.yaml'],
|
||||||
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
|
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
-118
@@ -1,118 +0,0 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
|
|
||||||
const projectRoot = process.cwd();
|
|
||||||
|
|
||||||
// Extensões permitidas
|
|
||||||
const allowedExtensions = /\.(ts|tsx|js|jsx|json|md|html|jsonc|yml|yaml|css)$/;
|
|
||||||
|
|
||||||
// Arquivos/Pastas a ignorar (já curados ou irrelevantes)
|
|
||||||
const ignoreDirs = [
|
|
||||||
'node_modules',
|
|
||||||
'.git',
|
|
||||||
'.gemini',
|
|
||||||
'dist',
|
|
||||||
'out',
|
|
||||||
'build',
|
|
||||||
'images',
|
|
||||||
'docs',
|
|
||||||
'.planning',
|
|
||||||
];
|
|
||||||
const ignoreFiles = [
|
|
||||||
'README.md',
|
|
||||||
'README.pt-BR.md',
|
|
||||||
'CHANGELOG.md',
|
|
||||||
'forge.config.ts',
|
|
||||||
'package.json',
|
|
||||||
'rename.js',
|
|
||||||
'rename-deep.js',
|
|
||||||
'task.md',
|
|
||||||
'walkthrough.md',
|
|
||||||
];
|
|
||||||
|
|
||||||
function walkAndReplace(dir) {
|
|
||||||
const files = fs.readdirSync(dir);
|
|
||||||
|
|
||||||
for (const file of files) {
|
|
||||||
if (ignoreDirs.includes(file)) continue;
|
|
||||||
|
|
||||||
const fullPath = path.join(dir, file);
|
|
||||||
const stat = fs.statSync(fullPath);
|
|
||||||
|
|
||||||
if (stat.isDirectory()) {
|
|
||||||
walkAndReplace(fullPath);
|
|
||||||
|
|
||||||
// Rename directory if it contains 'antigravity'
|
|
||||||
if (file.toLowerCase().includes('antigravity')) {
|
|
||||||
const newName = file.replace(/antigravity/gi, (match) => {
|
|
||||||
if (match === 'antigravity') return 'geminiNexus';
|
|
||||||
if (match === 'Antigravity') return 'GeminiNexus';
|
|
||||||
return 'geminiNexus';
|
|
||||||
});
|
|
||||||
const newPath = path.join(dir, newName);
|
|
||||||
fs.renameSync(fullPath, newPath);
|
|
||||||
console.log(`Renamed directory: ${fullPath} -> ${newPath}`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (ignoreFiles.includes(file)) continue;
|
|
||||||
|
|
||||||
if (allowedExtensions.test(file)) {
|
|
||||||
let content = fs.readFileSync(fullPath, 'utf-8');
|
|
||||||
const originalContent = content;
|
|
||||||
|
|
||||||
// Save original GitHub links
|
|
||||||
const repoUrlRegex = /github\.com\/Draculabo\/AntigravityManager/gi;
|
|
||||||
const tempUrlMarker = '___TEMP_GITHUB_URL___';
|
|
||||||
content = content.replace(repoUrlRegex, tempUrlMarker);
|
|
||||||
|
|
||||||
// 1. Strings exact match "Antigravity" -> "Gemini Nexus"
|
|
||||||
content = content.replace(/['"]Antigravity['"]/g, (match) => {
|
|
||||||
return match[0] + 'Gemini Nexus' + match[0];
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. String "Antigravity " -> "Gemini Nexus "
|
|
||||||
content = content.replace(/Antigravity /g, 'Gemini Nexus ');
|
|
||||||
|
|
||||||
// 3. String " Antigravity" -> " Gemini Nexus"
|
|
||||||
content = content.replace(/ Antigravity/g, ' Gemini Nexus');
|
|
||||||
|
|
||||||
// 4. Identifiers Antigravity -> GeminiNexus
|
|
||||||
content = content.replace(/Antigravity/g, 'GeminiNexus');
|
|
||||||
|
|
||||||
// 5. Identifiers antigravity -> geminiNexus
|
|
||||||
content = content.replace(/antigravity/g, 'geminiNexus');
|
|
||||||
|
|
||||||
// Restore GitHub links
|
|
||||||
content = content.replace(
|
|
||||||
new RegExp(tempUrlMarker, 'g'),
|
|
||||||
'github.com/Draculabo/AntigravityManager',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cleanup any double replacements
|
|
||||||
content = content.replace(/GeminiNexus Nexus/g, 'Gemini Nexus');
|
|
||||||
|
|
||||||
if (content !== originalContent) {
|
|
||||||
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
||||||
console.log(`Updated file content: ${fullPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rename file if it contains 'antigravity'
|
|
||||||
if (file.toLowerCase().includes('antigravity')) {
|
|
||||||
const newName = file.replace(/antigravity/gi, (match) => {
|
|
||||||
if (match === 'antigravity') return 'geminiNexus'; // For files like antigravityVersion.ts -> geminiNexusVersion.ts
|
|
||||||
if (match === 'Antigravity') return 'GeminiNexus';
|
|
||||||
return 'geminiNexus';
|
|
||||||
});
|
|
||||||
const newPath = path.join(dir, newName);
|
|
||||||
fs.renameSync(fullPath, newPath);
|
|
||||||
console.log(`Renamed file: ${fullPath} -> ${newPath}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Starting global deep rename of Antigravity to GeminiNexus...');
|
|
||||||
walkAndReplace(projectRoot);
|
|
||||||
console.log('Rename complete.');
|
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
@echo off
|
|
||||||
set PYTHONPATH=%~dp0
|
|
||||||
python -m cli.main %*
|
|
||||||
+73
-22
@@ -41,7 +41,9 @@ function color(code, text) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function header(text) {
|
function header(text) {
|
||||||
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
|
console.log(
|
||||||
|
`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function success(text) {
|
function success(text) {
|
||||||
@@ -145,10 +147,13 @@ function checkWindows() {
|
|||||||
|
|
||||||
for (const vswhere of vswherePaths) {
|
for (const vswhere of vswherePaths) {
|
||||||
try {
|
try {
|
||||||
const output = execSync(`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`, {
|
const output = execSync(
|
||||||
|
`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`,
|
||||||
|
{
|
||||||
stdio: ['ignore', 'pipe', 'ignore'],
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
}).trim();
|
},
|
||||||
|
).trim();
|
||||||
|
|
||||||
if (output) {
|
if (output) {
|
||||||
vsFound = true;
|
vsFound = true;
|
||||||
@@ -163,10 +168,9 @@ function checkWindows() {
|
|||||||
// Check registry fallback
|
// Check registry fallback
|
||||||
if (!vsFound) {
|
if (!vsFound) {
|
||||||
try {
|
try {
|
||||||
execSync(
|
execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0', {
|
||||||
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
|
stdio: 'ignore',
|
||||||
{ stdio: 'ignore' },
|
});
|
||||||
);
|
|
||||||
vsFound = true;
|
vsFound = true;
|
||||||
success('Visual Studio 2022 found (registry)');
|
success('Visual Studio 2022 found (registry)');
|
||||||
} catch {
|
} catch {
|
||||||
@@ -198,7 +202,10 @@ function checkMacOS() {
|
|||||||
const results = { ok: true, missing: [] };
|
const results = { ok: true, missing: [] };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
const xcodePath = execSync('xcode-select -p', {
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim();
|
||||||
if (xcodePath) {
|
if (xcodePath) {
|
||||||
success(`Xcode CLT found at: ${xcodePath}`);
|
success(`Xcode CLT found at: ${xcodePath}`);
|
||||||
}
|
}
|
||||||
@@ -323,14 +330,26 @@ async function installWindows() {
|
|||||||
// Check if winget is available
|
// Check if winget is available
|
||||||
if (!commandExists('winget')) {
|
if (!commandExists('winget')) {
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'));
|
console.log(
|
||||||
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
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('');
|
||||||
console.log(' During installation, select:');
|
console.log(' During installation, select:');
|
||||||
console.log(color(BOLD, ' "Desktop development with C++" workload'));
|
console.log(color(BOLD, ' "Desktop development with C++" workload'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' Or install via PowerShell (as Administrator):');
|
console.log(' Or install via PowerShell (as Administrator):');
|
||||||
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
console.log(
|
||||||
|
color(
|
||||||
|
DIM,
|
||||||
|
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
||||||
|
),
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,7 +417,9 @@ async function installLinux() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = await runCommand('sudo', [
|
const status = await runCommand('sudo', [
|
||||||
'apt-get', 'install', '-y',
|
'apt-get',
|
||||||
|
'install',
|
||||||
|
'-y',
|
||||||
'build-essential',
|
'build-essential',
|
||||||
'python3',
|
'python3',
|
||||||
'libsecret-1-dev',
|
'libsecret-1-dev',
|
||||||
@@ -421,7 +442,10 @@ async function installLinux() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = await runCommand('sudo', [
|
const status = await runCommand('sudo', [
|
||||||
'pacman', '-S', '--needed', '--noconfirm',
|
'pacman',
|
||||||
|
'-S',
|
||||||
|
'--needed',
|
||||||
|
'--noconfirm',
|
||||||
'base-devel',
|
'base-devel',
|
||||||
'python',
|
'python',
|
||||||
'libsecret',
|
'libsecret',
|
||||||
@@ -443,7 +467,9 @@ async function installLinux() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const status = await runCommand('sudo', [
|
const status = await runCommand('sudo', [
|
||||||
'dnf', 'install', '-y',
|
'dnf',
|
||||||
|
'install',
|
||||||
|
'-y',
|
||||||
'gcc-c++',
|
'gcc-c++',
|
||||||
'make',
|
'make',
|
||||||
'python3',
|
'python3',
|
||||||
@@ -471,7 +497,9 @@ async function installLinux() {
|
|||||||
console.log(' - libsecret development headers (for keytar)');
|
console.log(' - libsecret development headers (for keytar)');
|
||||||
console.log(' - pkg-config');
|
console.log(' - pkg-config');
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' Install them using your distribution package manager, then run npm install again.');
|
console.log(
|
||||||
|
' Install them using your distribution package manager, then run npm install again.',
|
||||||
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -520,7 +548,9 @@ async function main() {
|
|||||||
|
|
||||||
// ── Missing tools ──
|
// ── Missing tools ──
|
||||||
console.log('');
|
console.log('');
|
||||||
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
|
error(
|
||||||
|
'Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.',
|
||||||
|
);
|
||||||
|
|
||||||
if (checkOnly) {
|
if (checkOnly) {
|
||||||
console.log('');
|
console.log('');
|
||||||
@@ -528,7 +558,9 @@ async function main() {
|
|||||||
warn('Run "npm run setup" to install them automatically, or install manually:');
|
warn('Run "npm run setup" to install them automatically, or install manually:');
|
||||||
printManualInstructions(platform, results.missing);
|
printManualInstructions(platform, results.missing);
|
||||||
console.log('');
|
console.log('');
|
||||||
dim('npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.');
|
dim(
|
||||||
|
'npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.',
|
||||||
|
);
|
||||||
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
|
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -572,13 +604,23 @@ function printManualInstructions(platform, missing) {
|
|||||||
switch (platform) {
|
switch (platform) {
|
||||||
case 'win32':
|
case 'win32':
|
||||||
console.log(' 1. Download Visual Studio 2022 Build Tools:');
|
console.log(' 1. Download Visual Studio 2022 Build Tools:');
|
||||||
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
console.log(
|
||||||
|
color(
|
||||||
|
CYAN,
|
||||||
|
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
|
||||||
|
),
|
||||||
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' 2. Run the installer and select:');
|
console.log(' 2. Run the installer and select:');
|
||||||
console.log(color(BOLD, ' "Desktop development with C++"'));
|
console.log(color(BOLD, ' "Desktop development with C++"'));
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' 3. Or install via PowerShell (as Administrator):');
|
console.log(' 3. Or install via PowerShell (as Administrator):');
|
||||||
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
console.log(
|
||||||
|
color(
|
||||||
|
DIM,
|
||||||
|
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
||||||
|
),
|
||||||
|
);
|
||||||
console.log('');
|
console.log('');
|
||||||
console.log(' 4. Restart your terminal after installation.');
|
console.log(' 4. Restart your terminal after installation.');
|
||||||
break;
|
break;
|
||||||
@@ -594,13 +636,22 @@ function printManualInstructions(platform, missing) {
|
|||||||
const distro = detectLinuxDistro();
|
const distro = detectLinuxDistro();
|
||||||
if (distro === 'debian') {
|
if (distro === 'debian') {
|
||||||
console.log(' Run:');
|
console.log(' Run:');
|
||||||
console.log(color(DIM, ' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config'));
|
console.log(
|
||||||
|
color(
|
||||||
|
DIM,
|
||||||
|
' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config',
|
||||||
|
),
|
||||||
|
);
|
||||||
} else if (distro === 'arch') {
|
} else if (distro === 'arch') {
|
||||||
console.log(' Run:');
|
console.log(' Run:');
|
||||||
console.log(color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'));
|
console.log(
|
||||||
|
color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'),
|
||||||
|
);
|
||||||
} else if (distro === 'fedora') {
|
} else if (distro === 'fedora') {
|
||||||
console.log(' Run:');
|
console.log(' Run:');
|
||||||
console.log(color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'));
|
console.log(
|
||||||
|
color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
|
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,7 +115,9 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-hidden transition-all duration-200',
|
'relative overflow-hidden transition-all duration-200',
|
||||||
|
|
||||||
isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
|
isCurrent
|
||||||
|
? 'border-primary/50 bg-primary/10'
|
||||||
|
: 'hover:bg-accent/10 cursor-pointer border-white/5',
|
||||||
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
||||||
)}
|
)}
|
||||||
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
||||||
|
|||||||
+235
-187
@@ -235,54 +235,51 @@ export function CloudAccountCard({
|
|||||||
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
|
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
|
||||||
if (models.length === 0) return null;
|
if (models.length === 0) return null;
|
||||||
return (
|
return (
|
||||||
<div key={title} className="space-y-1">
|
<div key={title} className="mt-1 space-y-1">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1">
|
<h4 className="text-muted-foreground/60 mb-1.5 px-1 text-[10px] font-bold tracking-widest uppercase">
|
||||||
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
|
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</h4>
|
||||||
<div className="h-[1px] flex-1 bg-white/[0.04]" />
|
<div className="flex flex-col gap-1">
|
||||||
</div>
|
|
||||||
{models.map(([modelName, info]) => (
|
{models.map(([modelName, info]) => (
|
||||||
<div
|
<div
|
||||||
key={modelName}
|
key={modelName}
|
||||||
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-[13px] transition-all hover:bg-white/[0.03]"
|
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-1.5 py-1 text-[13px] transition-colors hover:bg-white/[0.03]"
|
||||||
>
|
>
|
||||||
<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 truncate font-medium transition-colors"
|
||||||
title={modelName}
|
title={modelName}
|
||||||
>
|
>
|
||||||
{formatModelDisplayName(modelName)}
|
{formatModelDisplayName(modelName)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex flex-col items-end gap-0.5">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className="text-muted-foreground text-[9px] leading-none opacity-80"
|
className="text-muted-foreground/70 min-w-[50px] text-right text-[9px]"
|
||||||
title={formatResetTimeTitleText(info.resetTime)}
|
title={formatResetTimeTitleText(info.resetTime)}
|
||||||
>
|
>
|
||||||
{formatResetTimeLabelText(info.resetTime)}
|
{formatResetTimeLabelText(info.resetTime)}
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-baseline gap-1">
|
|
||||||
<span
|
<span
|
||||||
className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`}
|
className={`w-9 text-right font-mono text-xs font-bold ${getQuotaTextColorClass(info.percentage)}`}
|
||||||
>
|
>
|
||||||
{info.percentage}%
|
{info.percentage}%
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
|
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(info.percentage)}`}
|
||||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyQuotaState = (
|
const emptyQuotaState = (
|
||||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
<div className="text-muted-foreground flex flex-col items-center justify-center py-6">
|
||||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
<Box className="mb-2 h-6 w-6 opacity-20" />
|
||||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -290,18 +287,20 @@ export function CloudAccountCard({
|
|||||||
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
|
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
|
||||||
const providerGroupedQuotaSection =
|
const providerGroupedQuotaSection =
|
||||||
providerStats && providerStats.visibleModels > 0 ? (
|
providerStats && providerStats.visibleModels > 0 ? (
|
||||||
<>
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between rounded-lg bg-white/[0.03] px-3 py-1.5 text-xs">
|
<div className="flex items-center justify-between rounded-lg border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
||||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
<span className="text-muted-foreground text-xs font-medium">
|
||||||
|
{t('settings.providerGroupings.overall')}
|
||||||
|
</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||||
>
|
>
|
||||||
{formatQuotaLabel(providerStats.overallPercentage)}
|
{formatQuotaLabel(providerStats.overallPercentage)}
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
|
className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
|
||||||
style={{
|
style={{
|
||||||
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
|
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
|
||||||
}}
|
}}
|
||||||
@@ -310,7 +309,7 @@ export function CloudAccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 space-y-2">
|
<div className="space-y-3">
|
||||||
{providerStats.providers.map((group) => (
|
{providerStats.providers.map((group) => (
|
||||||
<ProviderGroup
|
<ProviderGroup
|
||||||
key={group.providerKey}
|
key={group.providerKey}
|
||||||
@@ -326,7 +325,7 @@ export function CloudAccountCard({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
emptyQuotaState
|
emptyQuotaState
|
||||||
);
|
);
|
||||||
@@ -343,17 +342,17 @@ export function CloudAccountCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`group flex h-full flex-col overflow-hidden rounded-xl border border-white/[0.06] bg-card transition-all duration-200 hover:border-white/[0.12] ${isSelected ? 'ring-accent/30 border-accent/40 ring-2' : ''}`}
|
className={`group bg-card relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-300 hover:shadow-md ${isSelected ? 'border-primary/50 ring-primary/50 ring-1' : 'border-white/[0.06] hover:border-white/[0.15]'}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
<CardHeader className="flex flex-row items-start gap-4 space-y-0 p-5 pb-4">
|
||||||
{onToggleSelection && (
|
{onToggleSelection && (
|
||||||
<div
|
<div
|
||||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} bg-background/90 rounded-full p-2 transition-opacity`}
|
className={`absolute top-3 left-3 z-10 transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)}
|
onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)}
|
||||||
className="h-5 w-5 border-2"
|
className="bg-background/80 data-[state=checked]:bg-primary h-4 w-4 backdrop-blur-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -362,48 +361,93 @@ export function CloudAccountCard({
|
|||||||
<img
|
<img
|
||||||
src={account.avatar_url}
|
src={account.avatar_url}
|
||||||
alt={account.name || ''}
|
alt={account.name || ''}
|
||||||
className="bg-muted h-10 w-10 rounded-full border"
|
className="h-11 w-11 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground">
|
<div className="text-muted-foreground flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-lg font-medium">
|
||||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex-1 overflow-hidden">
|
|
||||||
<CardTitle className="truncate text-base font-semibold">
|
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden pt-0.5">
|
||||||
|
<CardTitle className="truncate text-base leading-tight font-semibold">
|
||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
<CardDescription className="text-muted-foreground/80 truncate text-xs font-medium">
|
||||||
|
{account.email}
|
||||||
|
</CardDescription>
|
||||||
|
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
account.status === 'rate_limited' || account.status === 'expired'
|
||||||
|
? 'destructive'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
className="h-4 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
|
||||||
|
>
|
||||||
|
{account.provider}
|
||||||
|
</Badge>
|
||||||
|
{account.is_active && (
|
||||||
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="h-4 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
|
{t('cloud.card.active')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{hasHighTier && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="h-4 animate-pulse border-blue-500/20 bg-blue-500/10 px-1.5 py-0 text-[9px] text-blue-400 uppercase"
|
||||||
|
>
|
||||||
|
{t('cloud.card.gemini3Ready')}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{validationBlockedStatusLabel && (
|
||||||
|
<span className="text-destructive mt-1 text-xs font-medium">
|
||||||
|
{validationBlockedStatusLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{shouldShowAiCredits && aiCredits && (
|
{shouldShowAiCredits && aiCredits && (
|
||||||
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
|
<div className="mt-2 flex w-fit items-center gap-1.5 rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-1">
|
||||||
<span>
|
<span className="text-[10px] font-semibold text-blue-400">
|
||||||
{t('cloud.card.aiCreditsValue', {
|
{t('cloud.card.aiCreditsValue', {
|
||||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{aiCredits.expiryDate && (
|
{aiCredits.expiryDate && (
|
||||||
<span className="text-muted-foreground opacity-70">
|
<>
|
||||||
·{' '}
|
<span className="h-2.5 w-[1px] bg-blue-500/30"></span>
|
||||||
|
<span className="text-[9px] font-medium text-blue-400/80">
|
||||||
{t('cloud.card.creditsExpiry', {
|
{t('cloud.card.creditsExpiry', {
|
||||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
{allModelEntries.length > 0 && (
|
{allModelEntries.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
|
||||||
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const hiddenCount = allModelEntries.filter(
|
const hiddenCount = allModelEntries.filter(
|
||||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||||
).length;
|
).length;
|
||||||
return hiddenCount > 0 ? (
|
return hiddenCount > 0 ? (
|
||||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
<EyeOff className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
);
|
);
|
||||||
@@ -412,15 +456,18 @@ export function CloudAccountCard({
|
|||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-64" align="end">
|
<DropdownMenuContent className="w-64" align="end">
|
||||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs">
|
||||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
{t('cloud.card.modelVisibility')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||||
{allModelEntries.map(([modelName]) => {
|
{allModelEntries.map(([modelName]) => {
|
||||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={modelName}
|
key={modelName}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="flex cursor-pointer items-center gap-2"
|
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isVisible}
|
checked={isVisible}
|
||||||
@@ -431,8 +478,9 @@ export function CloudAccountCard({
|
|||||||
saveConfig({ ...config, model_visibility: newVisibility });
|
saveConfig({ ...config, model_visibility: newVisibility });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-xs" title={modelName}>
|
<span className="truncate text-xs font-medium" title={modelName}>
|
||||||
{formatModelDisplayName(modelName)}
|
{formatModelDisplayName(modelName)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -445,105 +493,61 @@ export function CloudAccountCard({
|
|||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
|
||||||
|
>
|
||||||
<MoreVertical className="h-4 w-4" />
|
<MoreVertical className="h-4 w-4" />
|
||||||
<span className="sr-only">Menu</span>
|
<span className="sr-only">Menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
|
||||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
|
||||||
<Power className="mr-2 h-4 w-4" />
|
|
||||||
{t('cloud.card.useAccount')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
|
||||||
{t('cloud.card.refresh')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
|
||||||
<Fingerprint className="mr-2 h-4 w-4" />
|
|
||||||
{t('cloud.card.identityProfile')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDelete(account.id)}
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
<Trash className="mr-2 h-4 w-4" />
|
|
||||||
{t('cloud.card.delete')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent className="flex-1 pb-2">
|
|
||||||
<div className="mb-3 flex items-center justify-between">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
account.status === 'rate_limited' || account.status === 'expired'
|
|
||||||
? 'destructive'
|
|
||||||
: 'outline'
|
|
||||||
}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{account.provider.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
{account.is_active && (
|
|
||||||
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20">
|
|
||||||
{t('cloud.card.active')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{validationBlockedStatusLabel && (
|
|
||||||
<span className="text-destructive text-xs font-medium">
|
|
||||||
{validationBlockedStatusLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{hasHighTier && (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="animate-pulse border-blue-500/20 bg-blue-500/10 text-[10px] text-blue-500"
|
|
||||||
>
|
|
||||||
{t('cloud.card.gemini3Ready')}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{account.is_active ? (
|
|
||||||
<Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100">
|
|
||||||
<Power className="mr-1 h-3 w-3" />
|
|
||||||
{t('cloud.card.active')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSwitch(account.id)}
|
onClick={() => onSwitch(account.id)}
|
||||||
disabled={isSwitching}
|
disabled={isSwitching}
|
||||||
className="cursor-pointer"
|
className="cursor-pointer"
|
||||||
>
|
>
|
||||||
{isSwitching ? (
|
<Power className="mr-2 h-4 w-4" />
|
||||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
<span className="font-medium">{t('cloud.card.useAccount')}</span>
|
||||||
) : (
|
</DropdownMenuItem>
|
||||||
<Power className="mr-1 h-3 w-3" />
|
<DropdownMenuItem
|
||||||
)}
|
onClick={() => onRefresh(account.id)}
|
||||||
{t('cloud.card.use')}
|
disabled={isRefreshing}
|
||||||
</Button>
|
className="cursor-pointer"
|
||||||
)}
|
>
|
||||||
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('cloud.card.refresh')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onManageIdentity(account.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Fingerprint className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('cloud.card.identityProfile')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => onDelete(account.id)}
|
||||||
|
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
|
||||||
|
disabled={isDeleting}
|
||||||
|
>
|
||||||
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
|
<span>{t('cloud.card.delete')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<CardContent className="flex-1 px-5 pb-5">
|
||||||
|
<div className="space-y-4">
|
||||||
{providerGroupingsEnabled ? (
|
{providerGroupingsEnabled ? (
|
||||||
providerGroupedQuotaSection
|
providerGroupedQuotaSection
|
||||||
) : hasVisibleQuotaModels ? (
|
) : hasVisibleQuotaModels ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
{renderQuotaModelGroup('Google Gemini', geminiModels)}
|
||||||
<div className="pt-1" />
|
{claudeModels.length > 0 && renderQuotaModelGroup('Anthropic Claude', claudeModels)}
|
||||||
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
emptyQuotaState
|
emptyQuotaState
|
||||||
@@ -551,11 +555,36 @@ export function CloudAccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground">
|
<CardFooter className="flex items-center justify-between border-t border-white/[0.04] bg-white/[0.01] px-5 py-3">
|
||||||
<span>
|
<span className="text-muted-foreground/70 text-[11px] font-medium">
|
||||||
{t('cloud.card.used')}{' '}
|
{t('cloud.card.used')}{' '}
|
||||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
{account.is_active ? (
|
||||||
|
<span className="flex items-center gap-1.5 text-xs font-semibold text-emerald-400">
|
||||||
|
<span className="relative flex h-2 w-2">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onSwitch(account.id)}
|
||||||
|
disabled={isSwitching}
|
||||||
|
className="hover:bg-primary/10 hover:text-primary h-7 cursor-pointer px-3 text-xs font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isSwitching ? (
|
||||||
|
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Power className="mr-1.5 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
{t('cloud.card.use')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -613,22 +642,22 @@ export function CompactCloudAccountCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-card px-3 py-2 transition-all duration-200 hover:border-white/[0.12]">
|
<div className="group bg-card flex items-center gap-3 rounded-xl border border-white/[0.04] px-4 py-3 transition-all duration-200 hover:border-white/[0.1] hover:shadow-sm">
|
||||||
{account.avatar_url ? (
|
{account.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={account.avatar_url}
|
src={account.avatar_url}
|
||||||
alt={account.name || ''}
|
alt={account.name || ''}
|
||||||
className="bg-muted h-7 w-7 rounded-full border"
|
className="h-8 w-8 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground">
|
<div className="text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-xs font-semibold">
|
||||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
{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="mb-0.5 flex items-center gap-2">
|
||||||
<span className="truncate text-[13px] font-semibold">
|
<span className="text-foreground truncate text-[13px] leading-none font-semibold">
|
||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -637,63 +666,62 @@ export function CompactCloudAccountCard({
|
|||||||
? 'destructive'
|
? 'destructive'
|
||||||
: 'outline'
|
: 'outline'
|
||||||
}
|
}
|
||||||
className="shrink-0 text-[10px]"
|
className="h-4 shrink-0 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
|
||||||
>
|
>
|
||||||
{account.provider.toUpperCase()}
|
{account.provider}
|
||||||
</Badge>
|
</Badge>
|
||||||
{account.is_active && (
|
{account.is_active && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20"
|
className="h-4 shrink-0 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase"
|
||||||
>
|
>
|
||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
<div className="text-muted-foreground/80 flex items-center gap-3 text-[11px] font-medium">
|
||||||
<span className="truncate">{account.email}</span>
|
<span className="truncate">{account.email}</span>
|
||||||
{validationBlockedStatusLabel && (
|
{validationBlockedStatusLabel && (
|
||||||
<span className="text-destructive shrink-0 font-medium">
|
<span className="text-destructive shrink-0">{validationBlockedStatusLabel}</span>
|
||||||
{validationBlockedStatusLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{shouldShowAiCredits && aiCredits && (
|
{shouldShowAiCredits && aiCredits && (
|
||||||
<span className="shrink-0 text-blue-500">
|
<span className="flex shrink-0 items-center gap-1.5 text-blue-400">
|
||||||
|
<span>
|
||||||
{t('cloud.card.aiCreditsValue', {
|
{t('cloud.card.aiCreditsValue', {
|
||||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||||
})}
|
})}
|
||||||
|
</span>
|
||||||
{aiCredits.expiryDate && (
|
{aiCredits.expiryDate && (
|
||||||
<span className="text-muted-foreground">
|
<>
|
||||||
{' '}
|
<span className="h-2.5 w-[1px] bg-blue-500/30"></span>
|
||||||
·{' '}
|
<span className="text-blue-400/70">
|
||||||
{t('cloud.card.creditsExpiry', {
|
{t('cloud.card.creditsExpiry', {
|
||||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{compactModels.length > 0 && (
|
{compactModels.length > 0 && (
|
||||||
<div className="mt-1 flex items-center gap-1">
|
<div className="mt-2 flex items-center gap-1.5">
|
||||||
{compactModels.map(([modelName, info]) => (
|
{compactModels.map(([modelName, info]) => (
|
||||||
<TooltipProvider key={modelName}>
|
<TooltipProvider key={modelName}>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
|
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
className={`h-full rounded-full transition-all duration-300 ease-out ${getQuotaBarColorClass(info.percentage)}`}
|
||||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent side="bottom" className="text-xs font-medium">
|
||||||
<p className="text-xs">
|
|
||||||
{formatModelDisplayName(modelName)}: {info.percentage}%
|
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||||
</p>
|
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -706,29 +734,36 @@ export function CompactCloudAccountCard({
|
|||||||
{allModelEntries.length > 0 && (
|
{allModelEntries.length > 0 && (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
|
||||||
|
>
|
||||||
{(() => {
|
{(() => {
|
||||||
const hiddenCount = allModelEntries.filter(
|
const hiddenCount = allModelEntries.filter(
|
||||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||||
).length;
|
).length;
|
||||||
return hiddenCount > 0 ? (
|
return hiddenCount > 0 ? (
|
||||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
<EyeOff className="h-4 w-4" />
|
||||||
) : (
|
) : (
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-4 w-4" />
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent className="w-64" align="end">
|
<DropdownMenuContent className="w-56" align="end">
|
||||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
<DropdownMenuLabel className="text-xs">
|
||||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
{t('cloud.card.modelVisibility')}
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="max-h-[250px] overflow-y-auto p-1">
|
||||||
{allModelEntries.map(([modelName]) => {
|
{allModelEntries.map(([modelName]) => {
|
||||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
key={modelName}
|
key={modelName}
|
||||||
onSelect={(e) => e.preventDefault()}
|
onSelect={(e) => e.preventDefault()}
|
||||||
className="flex cursor-pointer items-center gap-2"
|
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isVisible}
|
checked={isVisible}
|
||||||
@@ -739,8 +774,9 @@ export function CompactCloudAccountCard({
|
|||||||
saveConfig({ ...config, model_visibility: newVisibility });
|
saveConfig({ ...config, model_visibility: newVisibility });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
|
className="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<span className="truncate text-xs" title={modelName}>
|
<span className="truncate text-xs font-medium" title={modelName}>
|
||||||
{formatModelDisplayName(modelName)}
|
{formatModelDisplayName(modelName)}
|
||||||
</span>
|
</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
@@ -752,27 +788,27 @@ export function CompactCloudAccountCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{account.is_active ? (
|
{account.is_active ? (
|
||||||
|
<div className="flex h-8 items-center px-2">
|
||||||
|
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
|
||||||
|
<span className="relative flex h-1.5 w-1.5">
|
||||||
|
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
|
||||||
|
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
|
||||||
|
</span>
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled
|
|
||||||
className="h-7 text-xs text-emerald-400 opacity-100"
|
|
||||||
>
|
|
||||||
<Power className="mr-1 h-3 w-3" />
|
|
||||||
{t('cloud.card.active')}
|
|
||||||
</Button>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onSwitch(account.id)}
|
onClick={() => onSwitch(account.id)}
|
||||||
disabled={isSwitching}
|
disabled={isSwitching}
|
||||||
className="h-7 cursor-pointer text-xs"
|
className="hover:bg-primary/10 hover:text-primary h-8 cursor-pointer px-3 text-xs font-medium transition-colors"
|
||||||
>
|
>
|
||||||
{isSwitching ? (
|
{isSwitching ? (
|
||||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
<RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Power className="mr-1 h-3 w-3" />
|
<Power className="mr-1.5 h-3.5 w-3.5" />
|
||||||
)}
|
)}
|
||||||
{t('cloud.card.use')}
|
{t('cloud.card.use')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -780,35 +816,47 @@ export function CompactCloudAccountCard({
|
|||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
<Button
|
||||||
<MoreVertical className="h-3.5 w-3.5" />
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
|
||||||
|
>
|
||||||
|
<MoreVertical className="h-4 w-4" />
|
||||||
<span className="sr-only">Menu</span>
|
<span className="sr-only">Menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end" className="w-48">
|
||||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
onClick={() => onSwitch(account.id)}
|
||||||
|
disabled={isSwitching}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
<Power className="mr-2 h-4 w-4" />
|
<Power className="mr-2 h-4 w-4" />
|
||||||
{t('cloud.card.useAccount')}
|
<span className="font-medium">{t('cloud.card.useAccount')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
onClick={() => onRefresh(account.id)}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t('cloud.card.refresh')}
|
<span>{t('cloud.card.refresh')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuItem
|
||||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
onClick={() => onManageIdentity(account.id)}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
<Fingerprint className="mr-2 h-4 w-4" />
|
<Fingerprint className="mr-2 h-4 w-4" />
|
||||||
{t('cloud.card.identityProfile')}
|
<span>{t('cloud.card.identityProfile')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={() => onDelete(account.id)}
|
onClick={() => onDelete(account.id)}
|
||||||
className="text-destructive focus:text-destructive"
|
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
|
||||||
disabled={isDeleting}
|
disabled={isDeleting}
|
||||||
>
|
>
|
||||||
<Trash className="mr-2 h-4 w-4" />
|
<Trash className="mr-2 h-4 w-4" />
|
||||||
{t('cloud.card.delete')}
|
<span>{t('cloud.card.delete')}</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|||||||
+248
-773
File diff suppressed because it is too large.
Load diff
@@ -134,7 +134,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
|
setTimeout(() => {
|
||||||
|
void refreshProfiles();
|
||||||
|
}, 0);
|
||||||
|
}, [open, refreshProfiles]);
|
||||||
|
|
||||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||||
if (actionLockRef.current) {
|
if (actionLockRef.current) {
|
||||||
@@ -343,7 +346,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
|
|
||||||
{!showLoadingPlaceholder
|
{!showLoadingPlaceholder
|
||||||
? history.map((version) => (
|
? history.map((version) => (
|
||||||
<div key={version.id} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
|
<div
|
||||||
|
key={version.id}
|
||||||
|
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3"
|
||||||
|
>
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="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>
|
||||||
|
|||||||
@@ -48,8 +48,12 @@ 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) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
|
if (config?.model_visibility) {
|
||||||
if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
|
setTimeout(() => setModelVisibilityMap(config.model_visibility), 0);
|
||||||
|
}
|
||||||
|
if (config?.provider_groupings_enabled !== undefined) {
|
||||||
|
setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0);
|
||||||
|
}
|
||||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||||
|
|
||||||
// Get all unique models from all accounts
|
// Get all unique models from all accounts
|
||||||
@@ -184,7 +188,7 @@ export function ModelVisibilitySettings() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={modelId}
|
key={modelId}
|
||||||
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
|
className="flex items-center space-x-3 rounded-lg p-2 transition-colors hover:bg-white/[0.03]"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`model-${modelId}`}
|
id={`model-${modelId}`}
|
||||||
|
|||||||
@@ -3,15 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
import { Play, Square, Loader2 } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
export const StatusBar: React.FC = () => {
|
||||||
|
|
||||||
interface StatusBarProps {
|
|
||||||
isCollapsed?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -44,112 +38,48 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isPending = startMutation.isPending || stopMutation.isPending;
|
const isPending = startMutation.isPending || stopMutation.isPending;
|
||||||
|
|
||||||
// Collapsed View
|
|
||||||
if (isCollapsed) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-center py-2">
|
<div className="flex items-center gap-2 rounded-md border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<button
|
|
||||||
onClick={handleToggle}
|
|
||||||
disabled={isLoading || isPending}
|
|
||||||
className={cn(
|
|
||||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
|
|
||||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
|
||||||
isRunning
|
|
||||||
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
|
||||||
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isPending ? (
|
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Power className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'h-1.5 w-1.5 rounded-full',
|
|
||||||
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{isLoading
|
|
||||||
? t('status.checking')
|
|
||||||
: isRunning
|
|
||||||
? t('status.running')
|
|
||||||
: t('status.stopped')}
|
|
||||||
</span>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expanded View
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
|
|
||||||
isRunning
|
|
||||||
? 'bg-emerald-500/[0.08]'
|
|
||||||
: 'bg-white/[0.02]',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2.5">
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
|
'h-2 w-2 rounded-full',
|
||||||
isRunning
|
isRunning
|
||||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||||
: 'bg-muted-foreground/50',
|
: 'bg-muted-foreground/50',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col">
|
<span
|
||||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
className={cn(
|
||||||
Status
|
'text-xs font-medium',
|
||||||
</span>
|
|
||||||
<span className={cn(
|
|
||||||
'text-xs font-medium leading-tight',
|
|
||||||
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
||||||
)}>
|
)}
|
||||||
{isLoading
|
>
|
||||||
? t('status.checking')
|
{isLoading ? '...' : isRunning ? 'Running' : 'Stopped'}
|
||||||
: isRunning
|
|
||||||
? t('status.running')
|
|
||||||
: t('status.stopped')}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={isLoading || isPending}
|
disabled={isLoading || isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
|
'h-7 w-7 rounded-md p-0',
|
||||||
isRunning
|
isRunning
|
||||||
? 'text-emerald-400 hover:bg-emerald-500/10'
|
? 'text-emerald-400 hover:bg-emerald-500/10'
|
||||||
: 'text-muted-foreground hover:bg-white/[0.06]',
|
: 'text-muted-foreground hover:bg-white/[0.06]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-3 w-3 animate-spin" />
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
) : isRunning ? (
|
) : isRunning ? (
|
||||||
<Square className="h-3 w-3 fill-current" />
|
<Square className="h-3.5 w-3.5 fill-current" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="h-3 w-3 fill-current" />
|
<Play className="h-3.5 w-3.5 fill-current" />
|
||||||
)}
|
)}
|
||||||
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
|
<span className="sr-only">{isRunning ? t('action.stop') : t('action.start')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Cloud } from 'lucide-react';
|
||||||
|
|
||||||
|
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
|
||||||
|
import { type GridLayout } from '@/components/CloudAccountList';
|
||||||
|
import type { CloudAccount } from '@/types/cloudAccount';
|
||||||
|
|
||||||
|
export const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
||||||
|
auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
|
||||||
|
'2-col': 'grid gap-3 grid-cols-2',
|
||||||
|
'3-col': 'grid gap-3 grid-cols-3',
|
||||||
|
list: 'grid gap-3 grid-cols-1',
|
||||||
|
compact: 'flex flex-col gap-2',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface AccountsGridProps {
|
||||||
|
accounts: CloudAccount[];
|
||||||
|
gridLayout: GridLayout;
|
||||||
|
selectedIds: Set<string>;
|
||||||
|
onToggleSelection: (id: string, selected: boolean) => void;
|
||||||
|
onRefresh: (id: string) => void;
|
||||||
|
onDelete: (id: string) => void;
|
||||||
|
onSwitch: (id: string) => void;
|
||||||
|
onManageIdentity: (id: string) => void;
|
||||||
|
refreshingAccountId?: string;
|
||||||
|
deletingAccountId?: string;
|
||||||
|
switchingAccountId?: string;
|
||||||
|
isRefreshPending: boolean;
|
||||||
|
isDeletePending: boolean;
|
||||||
|
isSwitchPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountsGrid(props: AccountsGridProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
accounts,
|
||||||
|
gridLayout,
|
||||||
|
selectedIds,
|
||||||
|
onToggleSelection,
|
||||||
|
onRefresh,
|
||||||
|
onDelete,
|
||||||
|
onSwitch,
|
||||||
|
onManageIdentity,
|
||||||
|
refreshingAccountId,
|
||||||
|
deletingAccountId,
|
||||||
|
switchingAccountId,
|
||||||
|
isRefreshPending,
|
||||||
|
isDeletePending,
|
||||||
|
isSwitchPending,
|
||||||
|
} = props;
|
||||||
|
return (
|
||||||
|
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
|
||||||
|
{accounts.map((account) =>
|
||||||
|
gridLayout === 'compact' ? (
|
||||||
|
<CompactCloudAccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSwitch={onSwitch}
|
||||||
|
onManageIdentity={onManageIdentity}
|
||||||
|
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
|
||||||
|
isDeleting={isDeletePending && deletingAccountId === account.id}
|
||||||
|
isSwitching={isSwitchPending && switchingAccountId === account.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CloudAccountCard
|
||||||
|
key={account.id}
|
||||||
|
account={account}
|
||||||
|
onRefresh={onRefresh}
|
||||||
|
onDelete={onDelete}
|
||||||
|
onSwitch={onSwitch}
|
||||||
|
onManageIdentity={onManageIdentity}
|
||||||
|
isSelected={selectedIds.has(account.id)}
|
||||||
|
onToggleSelection={onToggleSelection}
|
||||||
|
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
|
||||||
|
isDeleting={isDeletePending && deletingAccountId === account.id}
|
||||||
|
isSwitching={isSwitchPending && switchingAccountId === account.id}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
{accounts.length === 0 && (
|
||||||
|
<div className="col-span-full flex flex-col items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.01] py-20 text-center">
|
||||||
|
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.04]">
|
||||||
|
<Cloud className="text-muted-foreground/40 h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 text-sm font-medium">{t('cloud.list.noAccounts')}</div>
|
||||||
|
<p className="text-muted-foreground mt-1 max-w-xs text-xs leading-relaxed">
|
||||||
|
{t('cloud.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { clampQuotaPercentage, getQuotaStatus, type QuotaStatus } from '@/utils/quota-display';
|
||||||
|
|
||||||
|
interface AccountsHeaderProps {
|
||||||
|
totalAccounts: number;
|
||||||
|
activeAccounts: number;
|
||||||
|
rateLimitedAccounts: number;
|
||||||
|
overallQuotaPercentage: number | null;
|
||||||
|
overallQuotaStatus: QuotaStatus | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||||
|
high: 'bg-emerald-500',
|
||||||
|
medium: 'bg-amber-500',
|
||||||
|
low: 'bg-rose-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||||
|
high: 'text-emerald-400',
|
||||||
|
medium: 'text-amber-400',
|
||||||
|
low: 'text-rose-400',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AccountsHeader({
|
||||||
|
totalAccounts,
|
||||||
|
activeAccounts,
|
||||||
|
rateLimitedAccounts,
|
||||||
|
overallQuotaPercentage,
|
||||||
|
overallQuotaStatus,
|
||||||
|
}: AccountsHeaderProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const quotaStatus =
|
||||||
|
overallQuotaStatus ??
|
||||||
|
(overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
|
||||||
|
{/* Title Area */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<h2 className="text-foreground text-2xl font-semibold tracking-tight">
|
||||||
|
{t('cloud.title')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
|
||||||
|
{t('cloud.description')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* Total & Active combined stat */}
|
||||||
|
<div className="flex min-w-[120px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
|
||||||
|
<div className="flex items-baseline gap-2">
|
||||||
|
<span className="text-foreground text-2xl font-bold tracking-tight">
|
||||||
|
{totalAccounts}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm font-medium text-emerald-400">{activeAccounts} active</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground/70 mt-0.5 text-xs font-medium tracking-wider uppercase">
|
||||||
|
{t('cloud.card.actions')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Rate Limited stat (only if > 0) */}
|
||||||
|
{rateLimitedAccounts > 0 && (
|
||||||
|
<div className="flex min-w-[100px] flex-col justify-center rounded-xl border border-rose-500/10 bg-rose-500/[0.02] px-4 py-3">
|
||||||
|
<span className="text-2xl font-bold tracking-tight text-rose-400">
|
||||||
|
{rateLimitedAccounts}
|
||||||
|
</span>
|
||||||
|
<span className="mt-0.5 text-xs font-medium tracking-wider text-rose-400/60 uppercase">
|
||||||
|
{t('cloud.card.rateLimited')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Global Quota */}
|
||||||
|
{overallQuotaPercentage !== null && quotaStatus && (
|
||||||
|
<div className="flex min-w-[140px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span
|
||||||
|
className={`text-2xl font-bold tracking-tight ${GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
|
||||||
|
>
|
||||||
|
{overallQuotaPercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
|
<div
|
||||||
|
className={`h-full rounded-full transition-all duration-700 ease-in-out ${GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
|
||||||
|
style={{ width: `${clampQuotaPercentage(overallQuotaPercentage)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground/70 mt-1.5 text-xs font-medium tracking-wider uppercase">
|
||||||
|
{t('cloud.globalQuota')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,272 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import {
|
||||||
|
Check,
|
||||||
|
CheckSquare,
|
||||||
|
Columns2,
|
||||||
|
Columns3,
|
||||||
|
Download,
|
||||||
|
FileDown,
|
||||||
|
LayoutGrid,
|
||||||
|
LayoutList,
|
||||||
|
List,
|
||||||
|
MoreHorizontal,
|
||||||
|
Plus,
|
||||||
|
RefreshCcw,
|
||||||
|
SortAsc,
|
||||||
|
Upload,
|
||||||
|
Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
|
||||||
|
interface AccountsToolbarProps {
|
||||||
|
autoSwitchEnabled: boolean | undefined;
|
||||||
|
isSettingsLoading: boolean;
|
||||||
|
onToggleAutoSwitch: (checked: boolean) => void;
|
||||||
|
isAutoSwitchPending: boolean;
|
||||||
|
selectedCount: number;
|
||||||
|
totalCount: number;
|
||||||
|
onToggleSelectAll: () => void;
|
||||||
|
onForcePoll: () => void;
|
||||||
|
isForcePollPending: boolean;
|
||||||
|
onSyncLocal: () => void;
|
||||||
|
isSyncPending: boolean;
|
||||||
|
onOpenExportDialog: () => void;
|
||||||
|
onOpenImportDialog: () => void;
|
||||||
|
onOpenAddDialog: () => void;
|
||||||
|
currentSort: string;
|
||||||
|
sortOptions: readonly string[];
|
||||||
|
sortI18nKeys: Record<string, string>;
|
||||||
|
onSortChange: (sort: string) => void;
|
||||||
|
gridLayout: string;
|
||||||
|
onLayoutChange: (layout: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountsToolbar(props: AccountsToolbarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
autoSwitchEnabled,
|
||||||
|
isSettingsLoading,
|
||||||
|
onToggleAutoSwitch,
|
||||||
|
isAutoSwitchPending,
|
||||||
|
selectedCount,
|
||||||
|
totalCount,
|
||||||
|
onToggleSelectAll,
|
||||||
|
onForcePoll,
|
||||||
|
isForcePollPending,
|
||||||
|
onSyncLocal,
|
||||||
|
isSyncPending,
|
||||||
|
onOpenExportDialog,
|
||||||
|
onOpenImportDialog,
|
||||||
|
onOpenAddDialog,
|
||||||
|
currentSort,
|
||||||
|
sortOptions,
|
||||||
|
sortI18nKeys,
|
||||||
|
onSortChange,
|
||||||
|
gridLayout,
|
||||||
|
onLayoutChange,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
{/* Primary Actions Group */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button onClick={onOpenAddDialog} className="cursor-pointer shadow-sm">
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.addAccount')}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onToggleSelectAll}
|
||||||
|
title={t('cloud.batch.selectAll')}
|
||||||
|
className={`cursor-pointer transition-colors ${selectedCount > 0 && selectedCount === totalCount ? 'border-primary/50 bg-primary/5 text-primary' : ''}`}
|
||||||
|
>
|
||||||
|
<CheckSquare
|
||||||
|
className={`mr-2 h-4 w-4 ${selectedCount > 0 && selectedCount === totalCount ? 'text-primary' : 'opacity-70'}`}
|
||||||
|
/>
|
||||||
|
{selectedCount > 0 ? `${selectedCount} Selected` : t('cloud.batch.selectAll')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Secondary Actions & View Options */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
|
||||||
|
{/* Layout Toggles */}
|
||||||
|
<div className="hidden items-center gap-1 rounded-lg border border-white/[0.08] bg-white/[0.02] p-1 sm:flex">
|
||||||
|
<TooltipProvider delayDuration={0}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={gridLayout === 'auto' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-pointer rounded-md"
|
||||||
|
onClick={() => onLayoutChange('auto')}
|
||||||
|
>
|
||||||
|
<LayoutGrid className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('cloud.layout.auto')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={gridLayout === '2-col' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-pointer rounded-md"
|
||||||
|
onClick={() => onLayoutChange('2-col')}
|
||||||
|
>
|
||||||
|
<Columns2 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('cloud.layout.twoCol')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={gridLayout === '3-col' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-pointer rounded-md"
|
||||||
|
onClick={() => onLayoutChange('3-col')}
|
||||||
|
>
|
||||||
|
<Columns3 className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('cloud.layout.threeCol')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={gridLayout === 'list' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-pointer rounded-md"
|
||||||
|
onClick={() => onLayoutChange('list')}
|
||||||
|
>
|
||||||
|
<List className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('cloud.layout.list')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant={gridLayout === 'compact' ? 'secondary' : 'ghost'}
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 cursor-pointer rounded-md"
|
||||||
|
onClick={() => onLayoutChange('compact')}
|
||||||
|
>
|
||||||
|
<LayoutList className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{t('cloud.layout.compact')}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort Dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" className="cursor-pointer gap-2 bg-white/[0.02]">
|
||||||
|
<SortAsc className="h-4 w-4 opacity-70" />
|
||||||
|
<span className="hidden text-xs sm:inline-block">{t(sortI18nKeys[currentSort])}</span>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||||
|
Sort By
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{sortOptions.map((option) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={option}
|
||||||
|
className="cursor-pointer"
|
||||||
|
onClick={() => onSortChange(option)}
|
||||||
|
>
|
||||||
|
{currentSort === option ? (
|
||||||
|
<Check className="mr-2 h-4 w-4" />
|
||||||
|
) : (
|
||||||
|
<div className="mr-2 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t(sortI18nKeys[option])}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
|
{/* More Options Dropdown */}
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="outline" size="icon" className="cursor-pointer bg-white/[0.02]">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-64">
|
||||||
|
{/* Auto-Switch Toggle embedded in menu */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap
|
||||||
|
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
||||||
|
{t('cloud.autoSwitch')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="auto-switch"
|
||||||
|
checked={!!autoSwitchEnabled}
|
||||||
|
onCheckedChange={onToggleAutoSwitch}
|
||||||
|
disabled={isSettingsLoading || isAutoSwitchPending}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||||
|
Actions
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onForcePoll}
|
||||||
|
disabled={isForcePollPending}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<RefreshCcw className={`mr-2 h-4 w-4 ${isForcePollPending ? 'animate-spin' : ''}`} />
|
||||||
|
{t('cloud.checkQuota')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={onSyncLocal}
|
||||||
|
disabled={isSyncPending}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<Download className={`mr-2 h-4 w-4 ${isSyncPending ? 'animate-bounce' : ''}`} />
|
||||||
|
{t('cloud.syncFromIDE')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuLabel className="text-muted-foreground text-xs tracking-wider uppercase">
|
||||||
|
Data
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuItem onClick={onOpenExportDialog} className="cursor-pointer">
|
||||||
|
<FileDown className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.exportImport.export')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onOpenImportDialog} className="cursor-pointer">
|
||||||
|
<Upload className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.exportImport.import')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Cloud, Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface AddAccountDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
authCode: string;
|
||||||
|
onAuthCodeChange: (code: string) => void;
|
||||||
|
selectedOAuthClientKey: string;
|
||||||
|
onOAuthClientChange: (key: string) => void;
|
||||||
|
oauthClients: Array<{ key: string; label: string; is_active: boolean }>;
|
||||||
|
isOAuthClientsLoading: boolean;
|
||||||
|
isSetActiveOAuthClientPending: boolean;
|
||||||
|
onOpenGoogleAuth: () => void;
|
||||||
|
onSubmitAuthCode: () => void;
|
||||||
|
isAddPending: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddAccountDialog(props: AddAccountDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
authCode,
|
||||||
|
onAuthCodeChange,
|
||||||
|
selectedOAuthClientKey,
|
||||||
|
onOAuthClientChange,
|
||||||
|
oauthClients,
|
||||||
|
isOAuthClientsLoading,
|
||||||
|
isSetActiveOAuthClientPending,
|
||||||
|
onOpenGoogleAuth,
|
||||||
|
onSubmitAuthCode,
|
||||||
|
isAddPending,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<span />
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('cloud.authDialog.title')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('cloud.authDialog.description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="oauth-client-select">{t('cloud.authDialog.oauthClient')}</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedOAuthClientKey || undefined}
|
||||||
|
onValueChange={onOAuthClientChange}
|
||||||
|
disabled={isOAuthClientsLoading || isSetActiveOAuthClientPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="oauth-client-select">
|
||||||
|
<SelectValue placeholder={t('cloud.authDialog.oauthClientPlaceholder')} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{oauthClients.map((client) => (
|
||||||
|
<SelectItem key={client.key} value={client.key}>
|
||||||
|
{client.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Button variant="outline" className="col-span-4" onClick={onOpenGoogleAuth}>
|
||||||
|
<Cloud className="mr-2 h-4 w-4" />
|
||||||
|
{t('cloud.authDialog.openLogin')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code">{t('cloud.authDialog.authCode')}</Label>
|
||||||
|
<Input
|
||||||
|
id="code"
|
||||||
|
placeholder={t('cloud.authDialog.placeholder')}
|
||||||
|
value={authCode}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
onAuthCodeChange(e.target.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<p className="text-muted-foreground text-xs">{t('cloud.authDialog.instruction')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onSubmitAuthCode} disabled={isAddPending || !authCode}>
|
||||||
|
{isAddPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('cloud.authDialog.verify')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { RefreshCw, Trash2, X } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface BatchActionBarProps {
|
||||||
|
selectedCount: number;
|
||||||
|
onClearSelection: () => void;
|
||||||
|
onRefreshSelected: () => void;
|
||||||
|
onDeleteSelected: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BatchActionBar({
|
||||||
|
selectedCount,
|
||||||
|
onClearSelection,
|
||||||
|
onRefreshSelected,
|
||||||
|
onDeleteSelected,
|
||||||
|
}: BatchActionBarProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
if (selectedCount <= 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border border-white/[0.1] px-6 py-2 shadow-2xl shadow-black/30">
|
||||||
|
<div className="flex items-center gap-2 border-r pr-4">
|
||||||
|
<span className="text-sm font-semibold">
|
||||||
|
{t('cloud.batch.selected', { count: selectedCount })}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6 rounded-full"
|
||||||
|
onClick={onClearSelection}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button variant="secondary" size="sm" onClick={onRefreshSelected}>
|
||||||
|
<RefreshCw className="mr-2 h-3 w-3" />
|
||||||
|
{t('cloud.batch.refresh')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onClick={onDeleteSelected}>
|
||||||
|
<Trash2 className="mr-2 h-3 w-3" />
|
||||||
|
{t('cloud.batch.delete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import type React from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface ExportImportDialogsProps {
|
||||||
|
isExportDialogOpen: boolean;
|
||||||
|
onExportDialogChange: (open: boolean) => void;
|
||||||
|
onExport: (stripTokens: boolean) => void;
|
||||||
|
isExportPending: boolean;
|
||||||
|
isImportDialogOpen: boolean;
|
||||||
|
onImportDialogChange: (open: boolean) => void;
|
||||||
|
importStrategy: 'merge' | 'overwrite' | 'skip-existing';
|
||||||
|
onImportStrategyChange: (strategy: 'merge' | 'overwrite' | 'skip-existing') => void;
|
||||||
|
importFileName: string;
|
||||||
|
onImportFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
importFileContent: string | null;
|
||||||
|
onImport: () => void;
|
||||||
|
isImportPending: boolean;
|
||||||
|
fileInputRef: React.RefObject<HTMLInputElement | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ExportImportDialogs(props: ExportImportDialogsProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const {
|
||||||
|
isExportDialogOpen,
|
||||||
|
onExportDialogChange,
|
||||||
|
onExport,
|
||||||
|
isExportPending,
|
||||||
|
isImportDialogOpen,
|
||||||
|
onImportDialogChange,
|
||||||
|
importStrategy,
|
||||||
|
onImportStrategyChange,
|
||||||
|
importFileName,
|
||||||
|
onImportFileSelect,
|
||||||
|
importFileContent,
|
||||||
|
onImport,
|
||||||
|
isImportPending,
|
||||||
|
fileInputRef,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Dialog open={isExportDialogOpen} onOpenChange={onExportDialogChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<span />
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('cloud.exportImport.exportTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('cloud.exportImport.exportDesc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter className="flex w-full flex-col gap-2 sm:flex-row sm:justify-center sm:space-x-0">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onExport(false)}
|
||||||
|
disabled={isExportPending}
|
||||||
|
className="w-full cursor-pointer sm:flex-1"
|
||||||
|
>
|
||||||
|
{isExportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('cloud.exportImport.includeTokens')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={() => onExport(true)}
|
||||||
|
disabled={isExportPending}
|
||||||
|
className="w-full cursor-pointer sm:flex-1"
|
||||||
|
>
|
||||||
|
{isExportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t('cloud.exportImport.stripTokens')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={isImportDialogOpen} onOpenChange={onImportDialogChange}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<span />
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('cloud.exportImport.importTitle')}</DialogTitle>
|
||||||
|
<DialogDescription>{t('cloud.exportImport.importDesc')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{t('cloud.exportImport.selectFile')}</Label>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".json"
|
||||||
|
onChange={onImportFileSelect}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{importFileName || t('cloud.exportImport.selectFile')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label>{t('cloud.exportImport.importStrategy')}</Label>
|
||||||
|
<Select value={importStrategy} onValueChange={onImportStrategyChange}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="merge">{t('cloud.exportImport.strategyMerge')}</SelectItem>
|
||||||
|
<SelectItem value="overwrite">
|
||||||
|
{t('cloud.exportImport.strategyOverwrite')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="skip-existing">
|
||||||
|
{t('cloud.exportImport.strategySkip')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onImport} disabled={!importFileContent || isImportPending}>
|
||||||
|
{isImportPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{isImportPending ? t('cloud.exportImport.importing') : t('cloud.exportImport.import')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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-muted-foreground select-none">
|
<div className="text-muted-foreground flex flex-1 p-2 text-xs whitespace-nowrap select-none">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
||||||
import { MonitorTab } from './advanced/MonitorTab';
|
|
||||||
import { ToolsTab } from './advanced/ToolsTab';
|
|
||||||
import { ReplayTab } from './advanced/ReplayTab';
|
|
||||||
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
|
|
||||||
import type { ProxyConfig } from '@/types/config';
|
|
||||||
|
|
||||||
interface ProxyAdvancedTabProps {
|
|
||||||
proxyConfig: ProxyConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Tabs defaultValue="monitor" className="space-y-5">
|
|
||||||
<TabsList className="grid w-full grid-cols-4">
|
|
||||||
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
|
|
||||||
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
<TabsContent value="monitor">
|
|
||||||
<MonitorTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="tools">
|
|
||||||
<ToolsTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="replay">
|
|
||||||
<ReplayTab />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="capabilities">
|
|
||||||
<CapabilitiesTab />
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -98,7 +98,7 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
<h3 className="text-base font-semibold">
|
<h3 className="text-base font-semibold">
|
||||||
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
|
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-muted-foreground text-[13px]">
|
||||||
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
|
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -113,7 +113,7 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-40 items-center justify-center">
|
<div className="flex h-40 items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -121,11 +121,11 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
<div key={provider} className="space-y-3">
|
<div key={provider} className="space-y-3">
|
||||||
{/* Provider header */}
|
{/* Provider header */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h4 className="text-[13px] font-semibold tracking-wide uppercase text-muted-foreground">
|
<h4 className="text-muted-foreground text-[13px] font-semibold tracking-wide uppercase">
|
||||||
{provider}
|
{provider}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
|
<span className="text-muted-foreground/60 text-[11px] tabular-nums">
|
||||||
{groupedModels[provider].length}
|
{groupedModels[provider].length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -140,23 +140,23 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className="group flex flex-col rounded-xl border border-white/[0.06] bg-card p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
className="group bg-card flex flex-col rounded-xl border border-white/[0.06] p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
||||||
>
|
>
|
||||||
{/* Model header */}
|
{/* Model header */}
|
||||||
<div className="mb-3 space-y-1">
|
<div className="mb-3 space-y-1">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<h5
|
<h5
|
||||||
className="text-[13px] font-semibold leading-tight truncate"
|
className="truncate text-[13px] leading-tight font-semibold"
|
||||||
title={model.displayName || model.id}
|
title={model.displayName || model.id}
|
||||||
>
|
>
|
||||||
{model.displayName || model.id}
|
{model.displayName || model.id}
|
||||||
</h5>
|
</h5>
|
||||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground/60 rounded-md bg-white/[0.04] px-1.5 py-0.5">
|
<span className="text-muted-foreground/60 shrink-0 rounded-md bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium">
|
||||||
{enabledCount}/{totalCount}
|
{enabledCount}/{totalCount}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p
|
<p
|
||||||
className="font-mono text-[11px] text-muted-foreground truncate"
|
className="text-muted-foreground truncate font-mono text-[11px]"
|
||||||
title={model.id}
|
title={model.id}
|
||||||
>
|
>
|
||||||
{model.id}
|
{model.id}
|
||||||
@@ -164,7 +164,7 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Capability pills */}
|
{/* Capability pills */}
|
||||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||||
const Icon = CAPABILITY_ICONS[iconKey];
|
const Icon = CAPABILITY_ICONS[iconKey];
|
||||||
@@ -174,8 +174,8 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
key={key}
|
key={key}
|
||||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
|
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
|
||||||
supported
|
supported
|
||||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/15'
|
? 'border border-emerald-500/15 bg-emerald-500/10 text-emerald-400'
|
||||||
: 'bg-white/[0.02] text-muted-foreground/50 border border-white/[0.04]'
|
: 'text-muted-foreground/50 border border-white/[0.04] bg-white/[0.02]'
|
||||||
}`}
|
}`}
|
||||||
title={label}
|
title={label}
|
||||||
>
|
>
|
||||||
@@ -190,17 +190,17 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
{hasLimits && (
|
{hasLimits && (
|
||||||
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
|
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
|
||||||
{model.limits.maxTokens != null && (
|
{model.limits.maxTokens != null && (
|
||||||
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
CTX {formatTokenCount(model.limits.maxTokens)}
|
CTX {formatTokenCount(model.limits.maxTokens)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{model.limits.maxOutputTokens != null && (
|
{model.limits.maxOutputTokens != null && (
|
||||||
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
|
||||||
OUT {formatTokenCount(model.limits.maxOutputTokens)}
|
OUT {formatTokenCount(model.limits.maxOutputTokens)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{model.limits.thinkingBudget != null && (
|
{model.limits.thinkingBudget != null && (
|
||||||
<span className="inline-flex items-center rounded-md bg-blue-500/10 border border-blue-500/15 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
<span className="inline-flex items-center rounded-md border border-blue-500/15 bg-blue-500/10 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
||||||
THINK {formatTokenCount(model.limits.thinkingBudget)}
|
THINK {formatTokenCount(model.limits.thinkingBudget)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -215,12 +215,10 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
|
|
||||||
{models.length === 0 && (
|
{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">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/[0.1] py-16 text-center">
|
||||||
<Layers className="h-8 w-8 text-muted-foreground/30" />
|
<Layers className="text-muted-foreground/30 h-8 w-8" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-[13px] font-medium">No models found</p>
|
<p className="text-[13px] font-medium">No models found</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-muted-foreground text-xs">Check your provider configurations</p>
|
||||||
Check your provider configurations
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export const ReplayTab: React.FC = () => {
|
|||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: t('proxy.advanced.replay.error'),
|
title: t('proxy.advanced.replay.error'),
|
||||||
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
description:
|
||||||
|
error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
setSelectedRequestId(null);
|
setSelectedRequestId(null);
|
||||||
@@ -76,7 +77,10 @@ export const ReplayTab: React.FC = () => {
|
|||||||
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
|
<Badge
|
||||||
|
variant="default"
|
||||||
|
className="border-emerald-500/20 bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20"
|
||||||
|
>
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -99,7 +103,9 @@ export const ReplayTab: React.FC = () => {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
|
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
|
||||||
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
|
<p className="text-muted-foreground text-[13px]">
|
||||||
|
{t('proxy.advanced.replay.description')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -113,10 +119,12 @@ export const ReplayTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
<div className="space-y-1 border-b border-white/[0.06] px-5 py-4">
|
||||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
|
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
|
||||||
<p className="text-xs text-muted-foreground">{t('proxy.advanced.replay.clickToReplay')}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('proxy.advanced.replay.clickToReplay')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{requestsLoading ? (
|
{requestsLoading ? (
|
||||||
@@ -125,8 +133,8 @@ export const ReplayTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : requests.length === 0 ? (
|
) : 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]">
|
<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" />
|
<Clock className="text-muted-foreground/30 h-6 w-6" />
|
||||||
<p className="text-[13px] text-muted-foreground">
|
<p className="text-muted-foreground text-[13px]">
|
||||||
{t('proxy.advanced.replay.noRequests')}
|
{t('proxy.advanced.replay.noRequests')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -143,7 +151,7 @@ export const ReplayTab: React.FC = () => {
|
|||||||
<span className="text-[13px] font-medium">
|
<span className="text-[13px] font-medium">
|
||||||
{request.method} {request.endpoint}
|
{request.method} {request.endpoint}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{formatTimestamp(request.timestamp)}
|
{formatTimestamp(request.timestamp)}
|
||||||
{request.duration && ` · ${request.duration}ms`}
|
{request.duration && ` · ${request.duration}ms`}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
@@ -6,17 +6,42 @@ import { ipc } from '@/ipc/manager';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { StatCard } from '@/components/usage/StatCard';
|
import { StatCard } from '@/components/usage/StatCard';
|
||||||
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
|
import {
|
||||||
|
Loader2,
|
||||||
|
Copy,
|
||||||
|
CheckCircle,
|
||||||
|
ShieldAlert,
|
||||||
|
XCircle,
|
||||||
|
Terminal,
|
||||||
|
Code2,
|
||||||
|
Braces,
|
||||||
|
Sparkles,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
const IDE_CONFIGS = [
|
const IDE_CONFIGS = [
|
||||||
|
{ key: 'opencode', label: 'OpenCode', icon: Sparkles },
|
||||||
{ key: 'vscode', label: 'VS Code', icon: Code2 },
|
{ key: 'vscode', label: 'VS Code', icon: Code2 },
|
||||||
{ key: 'cursor', label: 'Cursor', icon: Terminal },
|
{ key: 'cursor', label: 'Cursor', icon: Terminal },
|
||||||
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
|
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export const ToolsTab: React.FC = () => {
|
interface ToolsTabProps {
|
||||||
|
availableModelIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ToolsTab: React.FC<ToolsTabProps> = ({
|
||||||
|
availableModelIds = ['gemini-3.1-pro-high'],
|
||||||
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
const [selectedModel, setSelectedModel] = useState<string>(availableModelIds[0]);
|
||||||
|
|
||||||
const { data: parityData, isLoading: parityLoading } = useQuery({
|
const { data: parityData, isLoading: parityLoading } = useQuery({
|
||||||
queryKey: ['proxyAdvanced', 'parityCounters'],
|
queryKey: ['proxyAdvanced', 'parityCounters'],
|
||||||
@@ -25,16 +50,11 @@ export const ToolsTab: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const ideQueries = useQueries({
|
const ideQueries = useQueries({
|
||||||
queries: [
|
queries: IDE_CONFIGS.map(({ key }) => ({
|
||||||
{
|
queryKey: ['proxyAdvanced', 'ideConfig', key, selectedModel],
|
||||||
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
|
queryFn: () =>
|
||||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
|
ipc.client.proxyAdvanced.generateIdeConfig({ ide: key, defaultModel: selectedModel }),
|
||||||
},
|
})),
|
||||||
{
|
|
||||||
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
|
|
||||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const parity = parityData?.success ? parityData.data : undefined;
|
const parity = parityData?.success ? parityData.data : undefined;
|
||||||
@@ -44,15 +64,21 @@ export const ToolsTab: React.FC = () => {
|
|||||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||||
};
|
};
|
||||||
|
|
||||||
const getIdeInstructions = (key: string): string => {
|
const getIdeInstructions = (key: string, index: number): string => {
|
||||||
if (key === 'claude-code') {
|
if (key === 'claude-code') {
|
||||||
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
|
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
|
||||||
}
|
}
|
||||||
const index = key === 'vscode' ? 0 : 1;
|
const result = ideQueries[index]?.data;
|
||||||
const result = ideQueries[index].data;
|
|
||||||
if (result?.success && result.data) {
|
if (result?.success && result.data) {
|
||||||
const d = result.data;
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
|
const d = result.data as any;
|
||||||
|
if (d.format === 'json') {
|
||||||
|
return JSON.stringify(d.content, null, 2);
|
||||||
|
}
|
||||||
|
if (d.format === 'env' || d.format === 'shell') {
|
||||||
|
return typeof d.content === 'string' ? d.content : JSON.stringify(d.content, null, 2);
|
||||||
|
}
|
||||||
|
return `${d.instructions || ''}\n${d.proxySetting || 'URL'}: http://localhost:8045\n${d.apiKeySetting || 'Key'}: YOUR_API_KEY`;
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
};
|
};
|
||||||
@@ -87,24 +113,45 @@ export const ToolsTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IDE Quick Setup */}
|
{/* IDE Quick Setup */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<div className="space-y-1">
|
||||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
|
<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>
|
<p className="text-muted-foreground text-xs">Quick configuration for popular IDEs</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5 space-y-3">
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">Default Model:</span>
|
||||||
|
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||||
|
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableModelIds.map((id) => (
|
||||||
|
<SelectItem key={id} value={id}>
|
||||||
|
{id}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 p-5">
|
||||||
{isIdeLoading ? (
|
{isIdeLoading ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex h-20 items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
|
IDE_CONFIGS.map(({ key, label, icon: Icon }, index) => {
|
||||||
const instructions = getIdeInstructions(key);
|
const instructions = getIdeInstructions(key, index);
|
||||||
|
const resultData = ideQueries[index]?.data;
|
||||||
|
const hasError = resultData && !resultData.success;
|
||||||
|
const errorMsg = resultData?.error || '';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<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 justify-between p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<Icon className="text-muted-foreground h-4 w-4" />
|
||||||
<span className="text-[13px] font-medium">{label}</span>
|
<span className="text-[13px] font-medium">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -112,13 +159,19 @@ export const ToolsTab: React.FC = () => {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
onClick={() => copyToClipboard(instructions)}
|
onClick={() => copyToClipboard(instructions)}
|
||||||
|
disabled={!instructions || hasError}
|
||||||
>
|
>
|
||||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{t('proxy.advanced.tools.copyConfig')}
|
{t('proxy.advanced.tools.copyConfig')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{instructions && (
|
{hasError && (
|
||||||
<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">
|
<div className="bg-card border-t border-white/[0.06] p-3 font-mono text-xs text-amber-500/80">
|
||||||
|
{errorMsg}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{instructions && !hasError && (
|
||||||
|
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||||
{instructions}
|
{instructions}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -128,7 +181,6 @@ export const ToolsTab: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,190 +0,0 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
|
||||||
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
interface TrafficLogEntry {
|
|
||||||
timestamp: string;
|
|
||||||
direction: string;
|
|
||||||
requestId: string;
|
|
||||||
endpoint: string;
|
|
||||||
method?: string;
|
|
||||||
status?: number;
|
|
||||||
durationMs?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TrafficLogTableProps {
|
|
||||||
logs: TrafficLogEntry[];
|
|
||||||
isLoading: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
|
|
||||||
type SortDir = 'asc' | 'desc';
|
|
||||||
|
|
||||||
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
|
||||||
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
|
||||||
return sortDir === 'asc' ? (
|
|
||||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
|
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
|
||||||
|
|
||||||
const toggleSort = (key: SortKey) => {
|
|
||||||
if (sortKey === key) {
|
|
||||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
|
||||||
} else {
|
|
||||||
setSortKey(key);
|
|
||||||
setSortDir('asc');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const sortedLogs = useMemo(() => {
|
|
||||||
const dir = sortDir === 'asc' ? 1 : -1;
|
|
||||||
return [...logs].sort((a, b) => {
|
|
||||||
switch (sortKey) {
|
|
||||||
case 'timestamp':
|
|
||||||
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
|
|
||||||
case 'direction':
|
|
||||||
return (a.direction || '').localeCompare(b.direction || '') * dir;
|
|
||||||
case 'endpoint':
|
|
||||||
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
|
|
||||||
case 'method':
|
|
||||||
return (a.method || '').localeCompare(b.method || '') * dir;
|
|
||||||
case 'status': {
|
|
||||||
const aStatus = a.status ?? -1;
|
|
||||||
const bStatus = b.status ?? -1;
|
|
||||||
return (aStatus - bStatus) * dir;
|
|
||||||
}
|
|
||||||
case 'duration': {
|
|
||||||
const aDur = a.durationMs ?? -1;
|
|
||||||
const bDur = b.durationMs ?? -1;
|
|
||||||
return (aDur - bDur) * dir;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [logs, sortKey, sortDir]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
|
||||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
|
||||||
<h4 className="text-[13px] font-semibold">
|
|
||||||
{t('proxy.advanced.trafficLog.title')}
|
|
||||||
</h4>
|
|
||||||
</div>
|
|
||||||
<div className="p-5">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="flex h-32 items-center justify-center">
|
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
|
||||||
</div>
|
|
||||||
) : logs.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-[13px] text-muted-foreground">
|
|
||||||
{t('proxy.advanced.trafficLog.noLogs')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase text-muted-foreground">
|
|
||||||
<th
|
|
||||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
|
||||||
onClick={() => toggleSort('timestamp')}
|
|
||||||
>
|
|
||||||
{t('proxy.advanced.trafficLog.timestamp')}
|
|
||||||
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
|
||||||
onClick={() => toggleSort('direction')}
|
|
||||||
>
|
|
||||||
{t('proxy.advanced.trafficLog.direction')}
|
|
||||||
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
|
||||||
onClick={() => toggleSort('endpoint')}
|
|
||||||
>
|
|
||||||
{t('proxy.advanced.trafficLog.endpoint')}
|
|
||||||
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
|
||||||
onClick={() => toggleSort('method')}
|
|
||||||
>
|
|
||||||
{t('proxy.advanced.trafficLog.method')}
|
|
||||||
<SortIcon column="method" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
|
||||||
onClick={() => toggleSort('status')}
|
|
||||||
>
|
|
||||||
{t('proxy.advanced.trafficLog.status')}
|
|
||||||
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
<th
|
|
||||||
className="cursor-pointer pb-2 font-medium select-none"
|
|
||||||
onClick={() => toggleSort('duration')}
|
|
||||||
>
|
|
||||||
{t('proxy.advanced.trafficLog.duration')}
|
|
||||||
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
|
|
||||||
</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{sortedLogs.map((log, i) => (
|
|
||||||
<tr
|
|
||||||
key={`${log.requestId}-${i}`}
|
|
||||||
className="border-b border-white/[0.04] last:border-0"
|
|
||||||
>
|
|
||||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
|
||||||
{new Date(log.timestamp).toLocaleTimeString()}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4">
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={
|
|
||||||
log.direction === 'inbound'
|
|
||||||
? 'border-blue-500/30 text-blue-400'
|
|
||||||
: log.direction === 'outbound'
|
|
||||||
? 'border-emerald-500/30 text-emerald-400'
|
|
||||||
: 'border-slate-500/30 text-slate-400'
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{log.direction}
|
|
||||||
</Badge>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
|
|
||||||
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
|
|
||||||
<td className="py-2 pr-4 text-xs">
|
|
||||||
{log.status != null ? (
|
|
||||||
<Badge
|
|
||||||
variant={log.status >= 400 ? 'destructive' : 'secondary'}
|
|
||||||
className="text-xs"
|
|
||||||
>
|
|
||||||
{log.status}
|
|
||||||
</Badge>
|
|
||||||
) : (
|
|
||||||
'—'
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 text-xs">
|
|
||||||
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: 'bg-background text-foreground',
|
||||||
|
destructive:
|
||||||
|
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: 'default',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const Alert = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||||
|
>(({ className, variant, ...props }, ref) => (
|
||||||
|
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
|
||||||
|
));
|
||||||
|
Alert.displayName = 'Alert';
|
||||||
|
|
||||||
|
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<h5
|
||||||
|
ref={ref}
|
||||||
|
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
);
|
||||||
|
AlertTitle.displayName = 'AlertTitle';
|
||||||
|
|
||||||
|
const AlertDescription = React.forwardRef<
|
||||||
|
HTMLParagraphElement,
|
||||||
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
|
||||||
|
));
|
||||||
|
AlertDescription.displayName = 'AlertDescription';
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription };
|
||||||
@@ -9,16 +9,14 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default:
|
default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||||
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
|
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
|
||||||
outline:
|
outline:
|
||||||
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
|
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
|
||||||
secondary:
|
secondary:
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||||
ghost:
|
ghost: 'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
||||||
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
|
||||||
link: 'text-accent underline-offset-4 hover:underline',
|
link: 'text-accent underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
|
|||||||
@@ -26,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('text-sm font-semibold leading-none tracking-tight', className)}
|
className={cn('text-sm leading-none font-semibold tracking-tight', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background',
|
'peer ring-offset-background focus-visible:ring-accent/30 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||||
'gap-4 rounded-xl border border-white/[0.08] bg-card p-6 shadow-2xl shadow-black/30',
|
'bg-card gap-4 rounded-xl border border-white/[0.08] p-6 shadow-2xl shadow-black/30',
|
||||||
'duration-200',
|
'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=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%]',
|
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||||
@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/30 disabled:pointer-events-none">
|
<DialogPrimitive.Close className="focus:ring-ring/30 absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] outline-none select-none transition-colors focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'focus:text-foreground relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
inset && 'pl-8',
|
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(
|
||||||
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
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(
|
||||||
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -154,7 +154,11 @@ 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-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
className={cn(
|
||||||
|
'text-muted-foreground px-2 py-1.5 text-xs font-medium',
|
||||||
|
inset && 'pl-8',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
|||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-1 text-[13px] text-foreground shadow-none transition-colors duration-200',
|
'bg-muted/50 text-foreground flex h-9 w-full rounded-lg border border-white/[0.08] px-3 py-1 text-[13px] shadow-none transition-colors duration-200',
|
||||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
'file:text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||||
'placeholder:text-muted-foreground',
|
'placeholder:text-muted-foreground',
|
||||||
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
|
'focus-visible:border-accent/50 focus-visible:ring-accent/20 focus-visible:ring-1 focus-visible:outline-none',
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-2 text-[13px] text-foreground transition-colors duration-200',
|
'bg-muted/50 text-foreground flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] px-3 py-2 text-[13px] transition-colors duration-200',
|
||||||
'placeholder:text-muted-foreground',
|
'placeholder:text-muted-foreground',
|
||||||
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
|
'focus:border-accent/50 focus:ring-accent/20 focus:ring-1 focus:outline-none',
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
'[&>span]:line-clamp-1',
|
'[&>span]:line-clamp-1',
|
||||||
className,
|
className,
|
||||||
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] bg-popover text-popover-foreground shadow-xl shadow-black/20',
|
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] shadow-xl shadow-black/20',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
'data-[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-[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' &&
|
||||||
@@ -104,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-xs font-medium text-muted-foreground', className)}
|
className={cn('text-muted-foreground py-1.5 pr-2 pl-8 text-xs font-medium', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -117,8 +117,8 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] outline-none select-none transition-colors',
|
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none',
|
||||||
'focus:bg-white/[0.06] focus:text-foreground',
|
'focus:text-foreground focus:bg-white/[0.06]',
|
||||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
|
|||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-3.5 w-3.5 text-accent" />
|
<Check className="text-accent h-3.5 w-3.5" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return (
|
return <div className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)} {...props} />;
|
||||||
<div
|
|
||||||
className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton };
|
export { Skeleton };
|
||||||
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
|
|||||||
<SwitchPrimitives.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
'focus-visible:ring-ring/30 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
|
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
|
||||||
className,
|
className,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
|
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg border border-white/[0.04] p-1',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -28,8 +28,8 @@ const TabsTrigger = React.forwardRef<
|
|||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center justify-center rounded-md px-3 py-1 text-[13px] font-medium whitespace-nowrap transition-all duration-200',
|
'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',
|
'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]',
|
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-white/[0.08] data-[state=active]:shadow-sm',
|
||||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
|
||||||
'disabled:pointer-events-none disabled:opacity-50',
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -45,7 +45,7 @@ const TabsContent = React.forwardRef<
|
|||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
'animate-in focus-visible:ring-ring/30 mt-3 focus-visible:ring-2 focus-visible:outline-none',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'z-50 overflow-hidden rounded-lg border border-white/[0.08] bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg shadow-black/20',
|
'bg-popover text-popover-foreground z-50 overflow-hidden rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs shadow-lg shadow-black/20',
|
||||||
'animate-in fade-in-0 zoom-in-95',
|
'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-[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',
|
'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',
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
|
<div className="bg-card flex flex-col rounded-xl border border-white/[0.06]">
|
||||||
<div className="flex flex-col p-5">
|
<div className="flex flex-col p-5">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
|
<h3 className="text-foreground text-[13px] font-semibold tracking-tight">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
@@ -41,8 +41,8 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
<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">
|
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
@@ -53,9 +53,9 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
<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]">
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
|
||||||
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
|
<BarChart3 className="text-muted-foreground/60 h-5 w-5" />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
|
<p className="text-foreground text-[13px] font-medium">{emptyTitle}</p>
|
||||||
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||||
{emptyDescription}
|
{emptyDescription}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { calculateCost } from '@/constants/pricing';
|
||||||
|
|
||||||
|
export interface ModelCostEntry {
|
||||||
|
model: string;
|
||||||
|
inputTokens: number;
|
||||||
|
outputTokens: number;
|
||||||
|
inputCost: number;
|
||||||
|
outputCost: number;
|
||||||
|
totalCost: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CostReportCardProps {
|
||||||
|
models: ModelCostEntry[];
|
||||||
|
totalInputCost: number;
|
||||||
|
totalOutputCost: number;
|
||||||
|
totalCost: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CostReportCard: React.FC<CostReportCardProps> = ({
|
||||||
|
models,
|
||||||
|
totalInputCost,
|
||||||
|
totalOutputCost,
|
||||||
|
totalCost,
|
||||||
|
isLoading,
|
||||||
|
}) => {
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-card/50 border-white/[0.06]">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
|
||||||
|
Loading cost data...
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!models.length) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-card/50 border-white/[0.06]">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
|
||||||
|
No usage data for cost calculation
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-card/50 border-white/[0.06]">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b border-white/[0.06]">
|
||||||
|
<th className="py-2 pr-4 font-medium">Model</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Input Tokens</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Input Cost</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Output Tokens</th>
|
||||||
|
<th className="py-2 pr-4 text-right font-medium">Output Cost</th>
|
||||||
|
<th className="py-2 text-right font-medium">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/[0.04]">
|
||||||
|
{models.map((m) => (
|
||||||
|
<tr key={m.model} className="hover:bg-white/[0.02]">
|
||||||
|
<td className="text-foreground py-2 pr-4">{m.model}</td>
|
||||||
|
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
|
||||||
|
{formatNumber(m.inputTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
|
||||||
|
${m.inputCost.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
|
||||||
|
{formatNumber(m.outputTokens)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
|
||||||
|
${m.outputCost.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right font-mono font-medium text-emerald-500">
|
||||||
|
${m.totalCost.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-white/[0.08] font-medium">
|
||||||
|
<td className="text-foreground py-3 pr-4">Total</td>
|
||||||
|
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
|
||||||
|
{formatNumber(models.reduce((sum, m) => sum + m.inputTokens, 0))}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
|
||||||
|
${totalInputCost.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
|
||||||
|
{formatNumber(models.reduce((sum, m) => sum + m.outputTokens, 0))}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
|
||||||
|
${totalOutputCost.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
<td className="py-3 text-right font-mono font-bold text-emerald-500">
|
||||||
|
${totalCost.toFixed(2)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { TrendingUp, Clock, Calendar, CalendarDays } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UsageBucket {
|
||||||
|
totalTokens: number;
|
||||||
|
requests: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ForecastCardProps {
|
||||||
|
/** Hourly buckets for the selected period (always fetched for all ranges). */
|
||||||
|
hourlyData?: UsageBucket[];
|
||||||
|
/** Daily buckets for the selected period. */
|
||||||
|
dailyData?: UsageBucket[];
|
||||||
|
range: '24h' | '7d' | '30d';
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(n: number): string {
|
||||||
|
if (n >= 1_000_000) {
|
||||||
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (n >= 1_000) {
|
||||||
|
return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Usage Averages Card.
|
||||||
|
*
|
||||||
|
* Calculates REAL averages over the time the user actually used the proxy,
|
||||||
|
* not projections over the entire selected window.
|
||||||
|
*
|
||||||
|
* Logic:
|
||||||
|
* - totalTokens = sum of all buckets in the period
|
||||||
|
* - activeHours = number of hourly buckets with totalTokens > 0
|
||||||
|
* - activeDays = number of daily buckets with totalTokens > 0
|
||||||
|
* - avgPerHour = totalTokens / activeHours (real average, no padding)
|
||||||
|
* - avgPerDay = totalTokens / activeDays (real average, no padding)
|
||||||
|
*
|
||||||
|
* For the 24h range, avgPerDay equals totalTokens (since it's a single day).
|
||||||
|
* For 7d/30d, avgPerDay = totalTokens / activeDays.
|
||||||
|
*
|
||||||
|
* The card title says "Usage Averages" (not "Forecast") because these are
|
||||||
|
* real historical averages, not projections.
|
||||||
|
*/
|
||||||
|
export function ForecastCard({ hourlyData, dailyData, range }: ForecastCardProps) {
|
||||||
|
const stats = useMemo(() => {
|
||||||
|
const hours = hourlyData ?? [];
|
||||||
|
const days = dailyData ?? [];
|
||||||
|
|
||||||
|
const totalTokens =
|
||||||
|
hours.length > 0
|
||||||
|
? hours.reduce((acc, d) => acc + d.totalTokens, 0)
|
||||||
|
: days.reduce((acc, d) => acc + d.totalTokens, 0);
|
||||||
|
|
||||||
|
const totalRequests =
|
||||||
|
hours.length > 0
|
||||||
|
? hours.reduce((acc, d) => acc + d.requests, 0)
|
||||||
|
: days.reduce((acc, d) => acc + d.requests, 0);
|
||||||
|
|
||||||
|
// Active hours = hourly buckets where there was actual usage
|
||||||
|
const activeHours = hours.filter((d) => d.totalTokens > 0).length;
|
||||||
|
|
||||||
|
// Active days = daily buckets where there was actual usage
|
||||||
|
const activeDays = days.filter((d) => d.totalTokens > 0).length;
|
||||||
|
|
||||||
|
// Real average per active hour
|
||||||
|
const avgPerHour = activeHours > 0 ? totalTokens / activeHours : 0;
|
||||||
|
|
||||||
|
// Real average per active day
|
||||||
|
const avgPerDay = activeDays > 0 ? totalTokens / activeDays : 0;
|
||||||
|
|
||||||
|
// Average tokens per request
|
||||||
|
const avgPerRequest = totalRequests > 0 ? totalTokens / totalRequests : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalTokens,
|
||||||
|
totalRequests,
|
||||||
|
activeHours,
|
||||||
|
activeDays,
|
||||||
|
avgPerHour,
|
||||||
|
avgPerDay,
|
||||||
|
avgPerRequest,
|
||||||
|
};
|
||||||
|
}, [hourlyData, dailyData]);
|
||||||
|
|
||||||
|
const rangeLabel = range === '24h' ? 'Last 24h' : range === '7d' ? 'Last 7 days' : 'Last 30 days';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
|
<CardHeader className="p-4 pb-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<CardTitle className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||||
|
Usage Averages
|
||||||
|
</CardTitle>
|
||||||
|
<span className="text-muted-foreground/70 text-[10px]">{rangeLabel}</span>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="p-4 pt-0">
|
||||||
|
<div className="mt-2 space-y-3">
|
||||||
|
{/* Total consumed in period */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-0.5 flex items-center gap-1.5">
|
||||||
|
<TrendingUp className="h-3 w-3 text-emerald-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Total in period</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-lg font-semibold">
|
||||||
|
{formatNumber(stats.totalTokens)}{' '}
|
||||||
|
<span className="text-muted-foreground font-sans text-xs font-normal">tokens</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground/70 mt-0.5 text-[10px]">
|
||||||
|
{stats.activeHours > 0 &&
|
||||||
|
`${stats.activeHours} active hour${stats.activeHours !== 1 ? 's' : ''}`}
|
||||||
|
{stats.activeHours > 0 && stats.activeDays > 0 && ' · '}
|
||||||
|
{stats.activeDays > 0 &&
|
||||||
|
`${stats.activeDays} active day${stats.activeDays !== 1 ? 's' : ''}`}
|
||||||
|
{stats.totalRequests > 0 && ` · ${stats.totalRequests.toLocaleString()} requests`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/[0.06]" />
|
||||||
|
|
||||||
|
{/* Average per active hour */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-3 w-3 text-blue-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Avg / active hour</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{formatNumber(stats.avgPerHour)}
|
||||||
|
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
|
||||||
|
tokens/h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average per active day */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Calendar className="h-3 w-3 text-violet-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Avg / active day</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{formatNumber(stats.avgPerDay)}
|
||||||
|
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
|
||||||
|
tokens/d
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Average per request */}
|
||||||
|
{stats.totalRequests > 0 && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CalendarDays className="h-3 w-3 text-amber-400" />
|
||||||
|
<span className="text-muted-foreground text-xs">Avg / request</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
|
||||||
|
{formatNumber(stats.avgPerRequest)}
|
||||||
|
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
|
||||||
|
tokens/req
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Loader2, RefreshCw, RotateCcw } from 'lucide-react';
|
||||||
|
import { MODEL_PRICING } from '@/constants/pricing';
|
||||||
|
|
||||||
|
export interface ModelPricingDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
customPricing: Record<string, { input: number; output: number; source?: string }>;
|
||||||
|
onSavePricing: (
|
||||||
|
pricing: Record<string, { input: number; output: number; source: string }>,
|
||||||
|
) => void;
|
||||||
|
availableModels: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ModelPricingDialog: React.FC<ModelPricingDialogProps> = ({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
customPricing,
|
||||||
|
onSavePricing,
|
||||||
|
availableModels,
|
||||||
|
}) => {
|
||||||
|
const [localPricing, setLocalPricing] = useState<
|
||||||
|
Record<string, { input: number; output: number; source: string }>
|
||||||
|
>({});
|
||||||
|
const [isFetching, setIsFetching] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
// Initialize local state with custom pricing or empty if default
|
||||||
|
const initial: Record<string, { input: number; output: number; source: string }> = {};
|
||||||
|
Object.entries(customPricing || {}).forEach(([model, price]) => {
|
||||||
|
initial[model] = {
|
||||||
|
input: price.input,
|
||||||
|
output: price.output,
|
||||||
|
source: price.source || 'Custom',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setLocalPricing(initial);
|
||||||
|
}
|
||||||
|
}, [open, customPricing]);
|
||||||
|
|
||||||
|
const handlePriceChange = (model: string, type: 'input' | 'output', value: string) => {
|
||||||
|
const numValue = parseFloat(value);
|
||||||
|
if (isNaN(numValue) && value !== '') return;
|
||||||
|
|
||||||
|
setLocalPricing((prev) => {
|
||||||
|
const current = prev[model] || {
|
||||||
|
input: getBasePrice(model, 'input'),
|
||||||
|
output: getBasePrice(model, 'output'),
|
||||||
|
source: 'Default',
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[model]: {
|
||||||
|
...current,
|
||||||
|
[type]: value === '' ? 0 : numValue,
|
||||||
|
source: 'Custom',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = (model: string) => {
|
||||||
|
setLocalPricing((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
delete next[model];
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getBasePrice = (model: string, type: 'input' | 'output'): number => {
|
||||||
|
const normalizedModel = model.toLowerCase().replace('models/', '');
|
||||||
|
let pricing = MODEL_PRICING['default'];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(MODEL_PRICING)) {
|
||||||
|
if (normalizedModel.includes(key)) {
|
||||||
|
pricing = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pricing[type];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayPrice = (model: string, type: 'input' | 'output'): number | string => {
|
||||||
|
if (localPricing[model]) {
|
||||||
|
return localPricing[model][type];
|
||||||
|
}
|
||||||
|
return ''; // Show empty to display placeholder
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSource = (model: string) => {
|
||||||
|
if (localPricing[model]) {
|
||||||
|
return localPricing[model].source;
|
||||||
|
}
|
||||||
|
return 'Default';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOpenRouterPricing = async () => {
|
||||||
|
setIsFetching(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('https://openrouter.ai/api/v1/models');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.data) {
|
||||||
|
const newPricing = { ...localPricing };
|
||||||
|
|
||||||
|
// Try to match available models with OpenRouter models
|
||||||
|
availableModels.forEach((model) => {
|
||||||
|
// Simplistic matching - OpenRouter models often have provider prefixes
|
||||||
|
const orModel = data.data.find(
|
||||||
|
(m: any) =>
|
||||||
|
m.id.endsWith(model) || m.id.includes(model) || model.includes(m.id.split('/').pop()),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (orModel && orModel.pricing) {
|
||||||
|
// OpenRouter pricing is per token, we need per 1M tokens
|
||||||
|
const inputPrice = parseFloat(orModel.pricing.prompt) * 1_000_000;
|
||||||
|
const outputPrice = parseFloat(orModel.pricing.completion) * 1_000_000;
|
||||||
|
|
||||||
|
if (!isNaN(inputPrice) && !isNaN(outputPrice)) {
|
||||||
|
newPricing[model] = {
|
||||||
|
input: Number(inputPrice.toFixed(4)),
|
||||||
|
output: Number(outputPrice.toFixed(4)),
|
||||||
|
source: 'OpenRouter',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setLocalPricing(newPricing);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch OpenRouter pricing:', error);
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
onSavePricing(localPricing);
|
||||||
|
onOpenChange(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="flex max-h-[85vh] max-w-2xl flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Model Pricing Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Set custom pricing per model (USD per 1M tokens). These values are used to calculate
|
||||||
|
cost estimates.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="mb-2 flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={fetchOpenRouterPricing}
|
||||||
|
disabled={isFetching}
|
||||||
|
className="h-8 text-xs"
|
||||||
|
>
|
||||||
|
{isFetching ? (
|
||||||
|
<Loader2 className="mr-2 h-3 w-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="mr-2 h-3 w-3" />
|
||||||
|
)}
|
||||||
|
Fetch from OpenRouter
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="custom-scrollbar flex-1 overflow-y-auto rounded-md border pr-2">
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="bg-muted/50 sticky top-0 z-10">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 font-medium">Model</th>
|
||||||
|
<th className="w-[120px] px-4 py-3 font-medium">Input Price</th>
|
||||||
|
<th className="w-[120px] px-4 py-3 font-medium">Output Price</th>
|
||||||
|
<th className="w-[100px] px-4 py-3 font-medium">Source</th>
|
||||||
|
<th className="w-[60px] px-4 py-3 font-medium"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y">
|
||||||
|
{availableModels.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="text-muted-foreground px-4 py-8 text-center">
|
||||||
|
No models have been used yet.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
availableModels.map((model) => {
|
||||||
|
const source = getSource(model);
|
||||||
|
return (
|
||||||
|
<tr key={model} className="hover:bg-muted/30">
|
||||||
|
<td className="px-4 py-2 font-mono text-xs">{model}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder={getBasePrice(model, 'input').toString()}
|
||||||
|
value={getDisplayPrice(model, 'input')}
|
||||||
|
onChange={(e) => handlePriceChange(model, 'input', e.target.value)}
|
||||||
|
className="h-8 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder={getBasePrice(model, 'output').toString()}
|
||||||
|
value={getDisplayPrice(model, 'output')}
|
||||||
|
onChange={(e) => handlePriceChange(model, 'output', e.target.value)}
|
||||||
|
className="h-8 font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<Badge
|
||||||
|
variant={source === 'Default' ? 'outline' : 'secondary'}
|
||||||
|
className={`text-[10px] ${
|
||||||
|
source === 'Custom'
|
||||||
|
? 'bg-blue-500/10 text-blue-500 hover:bg-blue-500/20'
|
||||||
|
: source === 'OpenRouter'
|
||||||
|
? 'bg-purple-500/10 text-purple-500 hover:bg-purple-500/20'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{source}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
{source !== 'Default' && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-muted-foreground hover:text-foreground h-6 w-6"
|
||||||
|
onClick={() => handleReset(model)}
|
||||||
|
title="Reset to default"
|
||||||
|
>
|
||||||
|
<RotateCcw className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="mt-4">
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>Save Changes</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface ModelQuotaBarProps {
|
||||||
|
modelName: string;
|
||||||
|
usedTokens: number;
|
||||||
|
limitTokens?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelQuotaBar({ modelName, usedTokens, limitTokens }: ModelQuotaBarProps) {
|
||||||
|
const formatNumber = (n: number) => {
|
||||||
|
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
|
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
return n.toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
const usagePercentage = limitTokens ? (usedTokens / limitTokens) * 100 : 0;
|
||||||
|
const displayPercentage = limitTokens ? Math.min(Math.round(usagePercentage), 100) : null;
|
||||||
|
const barPercentage = limitTokens ? Math.min(usagePercentage, 100) : 100;
|
||||||
|
|
||||||
|
let colorClass = 'bg-slate-500'; // Default if no limit
|
||||||
|
if (limitTokens) {
|
||||||
|
if (usagePercentage < 80) colorClass = 'bg-emerald-500';
|
||||||
|
else if (usagePercentage < 90) colorClass = 'bg-amber-500';
|
||||||
|
else colorClass = 'bg-rose-500';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="group mb-3">
|
||||||
|
<div className="mb-1 flex items-center justify-between">
|
||||||
|
<span className="text-muted-foreground group-hover:text-foreground max-w-[140px] truncate text-xs transition-colors">
|
||||||
|
{modelName}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-foreground font-mono text-[11px] font-medium tabular-nums">
|
||||||
|
{formatNumber(usedTokens)}
|
||||||
|
{limitTokens ? ` / ${formatNumber(limitTokens)}` : ''}
|
||||||
|
</span>
|
||||||
|
{displayPercentage !== null && (
|
||||||
|
<span
|
||||||
|
className={clsx('w-8 text-right text-[10px] font-semibold', {
|
||||||
|
'text-emerald-400': usagePercentage < 80,
|
||||||
|
'text-amber-400': usagePercentage >= 80 && usagePercentage < 90,
|
||||||
|
'text-rose-400': usagePercentage >= 90,
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
{displayPercentage}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
|
<div
|
||||||
|
className={clsx('h-full rounded-full transition-all duration-500', colorClass)}
|
||||||
|
style={{ width: `${barPercentage}%`, opacity: 0.8 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+27
-29
@@ -51,7 +51,18 @@ export const MonitorTab: React.FC = () => {
|
|||||||
|
|
||||||
const metrics = metricsData?.success ? metricsData.data : undefined;
|
const metrics = metricsData?.success ? metricsData.data : undefined;
|
||||||
const circuitBreakers = cbData?.success ? cbData.data : undefined;
|
const circuitBreakers = cbData?.success ? cbData.data : undefined;
|
||||||
const logs = logsData?.success ? logsData.data : [];
|
const logs = logsData?.success
|
||||||
|
? (logsData.data || []).map((log) => ({
|
||||||
|
...log,
|
||||||
|
model: log.metadata?.model as string | undefined,
|
||||||
|
tokensPrompt: log.metadata?.tokensPrompt as number | undefined,
|
||||||
|
tokensCompletion: log.metadata?.tokensCompletion as number | undefined,
|
||||||
|
tokensTotal:
|
||||||
|
(((log.metadata?.tokensPrompt as number) || 0) +
|
||||||
|
((log.metadata?.tokensCompletion as number) || 0)) ||
|
||||||
|
undefined,
|
||||||
|
}))
|
||||||
|
: [];
|
||||||
const stats = statsData?.success ? statsData.data : undefined;
|
const stats = statsData?.success ? statsData.data : undefined;
|
||||||
|
|
||||||
const handleExport = async (format: 'json' | 'csv') => {
|
const handleExport = async (format: 'json' | 'csv') => {
|
||||||
@@ -112,11 +123,9 @@ export const MonitorTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Circuit Breakers */}
|
{/* Circuit Breakers */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||||
<h4 className="text-[13px] font-semibold">
|
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.circuitBreaker.title')}</h4>
|
||||||
{t('proxy.advanced.circuitBreaker.title')}
|
|
||||||
</h4>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
{cbLoading ? (
|
{cbLoading ? (
|
||||||
@@ -124,22 +133,23 @@ export const MonitorTab: React.FC = () => {
|
|||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : !circuitBreakers ? (
|
) : !circuitBreakers ? (
|
||||||
<p className="text-[13px] text-muted-foreground">No data</p>
|
<p className="text-muted-foreground text-[13px]">No data</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
{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
|
||||||
|
key={api}
|
||||||
|
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"
|
||||||
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-[13px] font-medium">{api}</span>
|
<span className="text-[13px] font-medium">{api}</span>
|
||||||
<Badge variant={getCircuitBadgeVariant(state.state)}>
|
<Badge variant={getCircuitBadgeVariant(state.state)}>{state.state}</Badge>
|
||||||
{state.state}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-muted-foreground">
|
<div className="text-muted-foreground mt-2 text-xs">
|
||||||
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
|
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
|
||||||
</div>
|
</div>
|
||||||
{state.lastFailure && (
|
{state.lastFailure && (
|
||||||
<div className="text-xs text-muted-foreground">
|
<div className="text-muted-foreground text-xs">
|
||||||
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
|
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
|
||||||
{new Date(state.lastFailure).toLocaleString()}
|
{new Date(state.lastFailure).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
@@ -155,14 +165,8 @@ export const MonitorTab: React.FC = () => {
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-[13px] font-semibold">
|
<h3 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h3>
|
||||||
{t('proxy.advanced.trafficLog.title')}
|
{stats && <span className="text-muted-foreground text-xs">Total: {stats.total}</span>}
|
||||||
</h3>
|
|
||||||
{stats && (
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
Total: {stats.total}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select value={direction} onValueChange={setDirection}>
|
<Select value={direction} onValueChange={setDirection}>
|
||||||
@@ -170,15 +174,9 @@ export const MonitorTab: React.FC = () => {
|
|||||||
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
|
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">
|
<SelectItem value="all">{t('proxy.advanced.trafficLog.all')}</SelectItem>
|
||||||
{t('proxy.advanced.trafficLog.all')}
|
<SelectItem value="inbound">{t('proxy.advanced.trafficLog.inbound')}</SelectItem>
|
||||||
</SelectItem>
|
<SelectItem value="outbound">{t('proxy.advanced.trafficLog.outbound')}</SelectItem>
|
||||||
<SelectItem value="inbound">
|
|
||||||
{t('proxy.advanced.trafficLog.inbound')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="outbound">
|
|
||||||
{t('proxy.advanced.trafficLog.outbound')}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
@@ -4,8 +4,15 @@ export const COLOR_COMPLETION = '#34d399'; // emerald-400
|
|||||||
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
||||||
|
|
||||||
export const PIE_COLORS = [
|
export const PIE_COLORS = [
|
||||||
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
|
'#60a5fa',
|
||||||
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
|
'#34d399',
|
||||||
|
'#fbbf24',
|
||||||
|
'#a78bfa',
|
||||||
|
'#f472b6',
|
||||||
|
'#38bdf8',
|
||||||
|
'#4ade80',
|
||||||
|
'#facc15',
|
||||||
|
'#c084fc',
|
||||||
];
|
];
|
||||||
|
|
||||||
export const MODEL_COLORS: Record<string, string> = {
|
export const MODEL_COLORS: Record<string, string> = {
|
||||||
@@ -31,10 +38,7 @@ export function BarTooltip({ indexValue, data }: any) {
|
|||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{keys.map((key: string) => (
|
{keys.map((key: string) => (
|
||||||
<div key={key} className="flex items-center gap-2 text-xs">
|
<div key={key} className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: colorMap[key] }} />
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: colorMap[key] }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{key}:</span>
|
<span className="text-muted-foreground">{key}:</span>
|
||||||
<span className="text-foreground font-mono font-medium">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
||||||
@@ -50,10 +54,7 @@ export function PieTooltip({ datum }: any) {
|
|||||||
return (
|
return (
|
||||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
<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">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: datum.color }} />
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: datum.color }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{datum.id}:</span>
|
<span className="text-muted-foreground">{datum.id}:</span>
|
||||||
<span className="text-foreground font-mono font-medium">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
||||||
@@ -66,12 +67,11 @@ export function PieTooltip({ datum }: any) {
|
|||||||
export function LineTooltip({ point }: any) {
|
export function LineTooltip({ point }: any) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
|
<p className="text-foreground mb-1 text-xs font-medium">
|
||||||
|
{point.data.xFormatted ?? point.data.x}
|
||||||
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: point.serieColor }} />
|
||||||
className="h-2 w-2 rounded-full"
|
|
||||||
style={{ backgroundColor: point.serieColor }}
|
|
||||||
/>
|
|
||||||
<span className="text-muted-foreground">{point.serieId}:</span>
|
<span className="text-muted-foreground">{point.serieId}:</span>
|
||||||
<span className="text-foreground font-mono font-medium">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ interface TrendInfo {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: React.ReactNode;
|
||||||
value: number | string;
|
value: number | string;
|
||||||
icon: LucideIcon;
|
icon: LucideIcon;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
@@ -82,10 +82,7 @@ function Sparkline({ data, accent }: { data: number[]; accent: string }) {
|
|||||||
</linearGradient>
|
</linearGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{/* Area fill */}
|
{/* Area fill */}
|
||||||
<polygon
|
<polygon points={`0,${h} ${points.join(' ')} ${w},${h}`} fill={`url(#sparkGrad-${accent})`} />
|
||||||
points={`0,${h} ${points.join(' ')} ${w},${h}`}
|
|
||||||
fill={`url(#sparkGrad-${accent})`}
|
|
||||||
/>
|
|
||||||
{/* Line */}
|
{/* Line */}
|
||||||
<polyline
|
<polyline
|
||||||
points={points.join(' ')}
|
points={points.join(' ')}
|
||||||
@@ -103,11 +100,7 @@ function TrendBadge({ trend }: { trend: TrendInfo }) {
|
|||||||
const isUp = trend.direction === 'up';
|
const isUp = trend.direction === 'up';
|
||||||
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
||||||
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
||||||
const color = isNeutral
|
const color = isNeutral ? 'text-muted-foreground' : isUp ? 'text-emerald-400' : 'text-rose-400';
|
||||||
? 'text-muted-foreground'
|
|
||||||
: isUp
|
|
||||||
? 'text-emerald-400'
|
|
||||||
: 'text-rose-400';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
||||||
@@ -130,7 +123,7 @@ export const StatCard: React.FC<StatCardProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
|
'group bg-card relative rounded-xl border border-white/[0.06] p-4 transition-all duration-200',
|
||||||
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,388 @@
|
|||||||
|
import React, { useState, useMemo } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { Loader2, ArrowUp, ArrowDown, ChevronRight, ChevronDown } from 'lucide-react';
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { calculateCost } from '@/constants/pricing';
|
||||||
|
|
||||||
|
interface TrafficLogEntry {
|
||||||
|
timestamp: string;
|
||||||
|
direction: string;
|
||||||
|
requestId: string;
|
||||||
|
endpoint: string;
|
||||||
|
method?: string;
|
||||||
|
status?: number;
|
||||||
|
durationMs?: number;
|
||||||
|
tokensPrompt?: number;
|
||||||
|
tokensCompletion?: number;
|
||||||
|
tokensTotal?: number;
|
||||||
|
model?: string;
|
||||||
|
body?: unknown;
|
||||||
|
headers?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficLogTableProps {
|
||||||
|
logs: TrafficLogEntry[];
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'model' | 'method' | 'status' | 'tokens' | 'cost' | 'duration';
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
function SortIcon({
|
||||||
|
column,
|
||||||
|
sortKey,
|
||||||
|
sortDir,
|
||||||
|
}: {
|
||||||
|
column: SortKey;
|
||||||
|
sortKey: SortKey;
|
||||||
|
sortDir: SortDir;
|
||||||
|
}) {
|
||||||
|
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
||||||
|
return sortDir === 'asc' ? (
|
||||||
|
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatTokens = (tokens?: number) => {
|
||||||
|
if (tokens == null) return '—';
|
||||||
|
if (tokens >= 1000000) return `${(tokens / 1000000).toFixed(1)}M`;
|
||||||
|
if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}K`;
|
||||||
|
return tokens.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely format a JSON body for display.
|
||||||
|
* Truncates very large bodies and handles non-object values.
|
||||||
|
*/
|
||||||
|
function formatBody(body: unknown, maxLength = 8000): string {
|
||||||
|
if (body == null) return 'No data';
|
||||||
|
try {
|
||||||
|
const str = typeof body === 'string' ? body : JSON.stringify(body, null, 2);
|
||||||
|
if (str.length > maxLength) {
|
||||||
|
return str.slice(0, maxLength) + `\n\n... [truncated ${str.length - maxLength} chars]`;
|
||||||
|
}
|
||||||
|
return str;
|
||||||
|
} catch {
|
||||||
|
return String(body);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract a short preview of the message content from a request/response body.
|
||||||
|
*/
|
||||||
|
function getBodyPreview(body: unknown): string | null {
|
||||||
|
if (!body || typeof body !== 'object') return null;
|
||||||
|
const obj = body as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Request body: extract first user message
|
||||||
|
if (Array.isArray(obj.messages)) {
|
||||||
|
const userMsg = obj.messages.find(
|
||||||
|
(m: Record<string, unknown>) => m.role === 'user',
|
||||||
|
);
|
||||||
|
if (userMsg) {
|
||||||
|
const content = typeof userMsg.content === 'string'
|
||||||
|
? userMsg.content
|
||||||
|
: JSON.stringify(userMsg.content);
|
||||||
|
return content.length > 120 ? content.slice(0, 120) + '…' : content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Response body: extract assistant content
|
||||||
|
if (Array.isArray(obj.choices)) {
|
||||||
|
const firstChoice = obj.choices[0] as Record<string, unknown> | undefined;
|
||||||
|
const message = firstChoice?.message as Record<string, unknown> | undefined;
|
||||||
|
if (message?.content && typeof message.content === 'string') {
|
||||||
|
return message.content.length > 120
|
||||||
|
? message.content.slice(0, 120) + '…'
|
||||||
|
: message.content;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const toggleSort = (key: SortKey) => {
|
||||||
|
if (sortKey === key) {
|
||||||
|
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||||
|
} else {
|
||||||
|
setSortKey(key);
|
||||||
|
setSortDir('asc');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleExpand = (logId: string) => {
|
||||||
|
setExpandedId((prev) => (prev === logId ? null : logId));
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortedLogs = useMemo(() => {
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
return [...logs].sort((a, b) => {
|
||||||
|
switch (sortKey) {
|
||||||
|
case 'timestamp':
|
||||||
|
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
|
||||||
|
case 'direction':
|
||||||
|
return (a.direction || '').localeCompare(b.direction || '') * dir;
|
||||||
|
case 'endpoint':
|
||||||
|
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
|
||||||
|
case 'model':
|
||||||
|
return (a.model || '').localeCompare(b.model || '') * dir;
|
||||||
|
case 'method':
|
||||||
|
return (a.method || '').localeCompare(b.method || '') * dir;
|
||||||
|
case 'status': {
|
||||||
|
const aStatus = a.status ?? -1;
|
||||||
|
const bStatus = b.status ?? -1;
|
||||||
|
return (aStatus - bStatus) * dir;
|
||||||
|
}
|
||||||
|
case 'tokens': {
|
||||||
|
const aTokens = a.tokensTotal ?? -1;
|
||||||
|
const bTokens = b.tokensTotal ?? -1;
|
||||||
|
return (aTokens - bTokens) * dir;
|
||||||
|
}
|
||||||
|
case 'cost': {
|
||||||
|
const aCost = calculateCost(a.model || '', a.tokensPrompt || 0, a.tokensCompletion || 0);
|
||||||
|
const bCost = calculateCost(b.model || '', b.tokensPrompt || 0, b.tokensCompletion || 0);
|
||||||
|
return (aCost - bCost) * dir;
|
||||||
|
}
|
||||||
|
case 'duration': {
|
||||||
|
const aDur = a.durationMs ?? -1;
|
||||||
|
const bDur = b.durationMs ?? -1;
|
||||||
|
return (aDur - bDur) * dir;
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [logs, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const COL_COUNT = 9;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
|
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||||
|
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="p-5">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex h-32 items-center justify-center">
|
||||||
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
|
</div>
|
||||||
|
) : logs.length === 0 ? (
|
||||||
|
<div className="text-muted-foreground py-8 text-center text-[13px]">
|
||||||
|
{t('proxy.advanced.trafficLog.noLogs')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-muted-foreground border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase">
|
||||||
|
{/* Expand indicator column */}
|
||||||
|
<th className="w-6 pb-2" />
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('timestamp')}
|
||||||
|
>
|
||||||
|
{t('proxy.advanced.trafficLog.timestamp')}
|
||||||
|
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('direction')}
|
||||||
|
>
|
||||||
|
{t('proxy.advanced.trafficLog.direction')}
|
||||||
|
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('endpoint')}
|
||||||
|
>
|
||||||
|
{t('proxy.advanced.trafficLog.endpoint')}
|
||||||
|
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('model')}
|
||||||
|
>
|
||||||
|
Model
|
||||||
|
<SortIcon column="model" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('status')}
|
||||||
|
>
|
||||||
|
{t('proxy.advanced.trafficLog.status')}
|
||||||
|
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('tokens')}
|
||||||
|
>
|
||||||
|
Input / Output
|
||||||
|
<SortIcon column="tokens" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pr-4 pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('duration')}
|
||||||
|
>
|
||||||
|
{t('proxy.advanced.trafficLog.duration')}
|
||||||
|
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
<th
|
||||||
|
className="cursor-pointer pb-2 font-medium select-none"
|
||||||
|
onClick={() => toggleSort('cost')}
|
||||||
|
>
|
||||||
|
Cost
|
||||||
|
<SortIcon column="cost" sortKey={sortKey} sortDir={sortDir} />
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sortedLogs.map((log, i) => {
|
||||||
|
const logKey = `${log.requestId}-${i}`;
|
||||||
|
const isExpanded = expandedId === logKey;
|
||||||
|
const preview = getBodyPreview(log.body);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment key={logKey}>
|
||||||
|
<tr
|
||||||
|
className={`cursor-pointer border-b border-white/[0.04] transition-colors last:border-0 ${
|
||||||
|
isExpanded
|
||||||
|
? 'bg-white/[0.03]'
|
||||||
|
: 'hover:bg-white/[0.02]'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleExpand(logKey)}
|
||||||
|
>
|
||||||
|
{/* Expand chevron */}
|
||||||
|
<td className="py-2 pr-1">
|
||||||
|
{isExpanded ? (
|
||||||
|
<ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="h-3.5 w-3.5 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="text-muted-foreground py-2 pr-4 font-mono text-xs">
|
||||||
|
{new Date(log.timestamp).toLocaleTimeString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4">
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
log.direction === 'inbound'
|
||||||
|
? 'border-blue-500/30 text-blue-400'
|
||||||
|
: log.direction === 'outbound'
|
||||||
|
? 'border-emerald-500/30 text-emerald-400'
|
||||||
|
: 'border-slate-500/30 text-slate-400'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{log.direction}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">{log.model || '—'}</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">
|
||||||
|
{log.status != null ? (
|
||||||
|
<Badge
|
||||||
|
variant={log.status >= 400 ? 'destructive' : 'secondary'}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{log.status}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">
|
||||||
|
{log.tokensTotal ? (
|
||||||
|
`${formatTokens(log.tokensPrompt)} / ${formatTokens(log.tokensCompletion)}`
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-4 text-xs">
|
||||||
|
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-xs text-emerald-400">
|
||||||
|
{log.tokensTotal && log.model
|
||||||
|
? `$${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}`
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{/* Expanded detail row */}
|
||||||
|
{isExpanded && (
|
||||||
|
<tr className="border-b border-white/[0.04]">
|
||||||
|
<td colSpan={COL_COUNT} className="p-0">
|
||||||
|
<div className="border-t border-white/[0.06] bg-white/[0.01] px-6 py-4">
|
||||||
|
{/* Preview line */}
|
||||||
|
{preview && (
|
||||||
|
<div className="mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs text-muted-foreground italic break-words">
|
||||||
|
"{preview}"
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Summary badges */}
|
||||||
|
<div className="mb-3 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Badge variant="outline" className="font-mono text-[10px]">
|
||||||
|
ID: {log.requestId.slice(0, 8)}
|
||||||
|
</Badge>
|
||||||
|
{log.model && (
|
||||||
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
|
{log.model}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{log.tokensPrompt != null && (
|
||||||
|
<Badge variant="outline" className="border-blue-500/20 text-blue-400 text-[10px]">
|
||||||
|
Input: {log.tokensPrompt.toLocaleString()} tokens
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{log.tokensCompletion != null && (
|
||||||
|
<Badge variant="outline" className="border-emerald-500/20 text-emerald-400 text-[10px]">
|
||||||
|
Output: {log.tokensCompletion.toLocaleString()} tokens
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{log.tokensTotal != null && log.model && (
|
||||||
|
<Badge variant="outline" className="border-amber-500/20 text-amber-400 text-[10px]">
|
||||||
|
Cost: ${calculateCost(log.model, log.tokensPrompt || 0, log.tokensCompletion || 0).toFixed(6)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{log.durationMs != null && (
|
||||||
|
<Badge variant="outline" className="text-[10px]">
|
||||||
|
{log.durationMs}ms
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body content */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-[10px] font-medium uppercase tracking-wider">
|
||||||
|
{log.direction === 'inbound' ? 'Request Body' : log.direction === 'outbound' ? 'Response Body' : 'Payload'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<pre className="custom-scrollbar max-h-[400px] overflow-auto rounded-lg bg-black/30 p-3 font-mono text-[11px] leading-relaxed text-foreground/80 whitespace-pre-wrap break-all">
|
||||||
|
{formatBody(log.body)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
// Costs in USD per 1M tokens
|
||||||
|
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
|
||||||
|
'gemini-3.1-pro': { input: 1.25, output: 5.0 },
|
||||||
|
'gemini-3.1-pro-preview': { input: 1.25, output: 5.0 },
|
||||||
|
'gemini-3-flash': { input: 0.075, output: 0.3 },
|
||||||
|
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
|
||||||
|
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
|
||||||
|
// fallback for unknowns
|
||||||
|
default: { input: 1.0, output: 4.0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function calculateCost(
|
||||||
|
model: string,
|
||||||
|
inputTokens: number,
|
||||||
|
outputTokens: number,
|
||||||
|
customPricing?: Record<string, { input: number; output: number }>,
|
||||||
|
): number {
|
||||||
|
// Check custom pricing first
|
||||||
|
if (customPricing && customPricing[model]) {
|
||||||
|
const cp = customPricing[model];
|
||||||
|
return (inputTokens / 1_000_000) * cp.input + (outputTokens / 1_000_000) * cp.output;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize model name roughly for default matching
|
||||||
|
const normalizedModel = model.toLowerCase().replace('models/', '');
|
||||||
|
let pricing = MODEL_PRICING['default'];
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(MODEL_PRICING)) {
|
||||||
|
if (normalizedModel.includes(key)) {
|
||||||
|
pricing = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
|
||||||
|
}
|
||||||
+20
-4
@@ -193,7 +193,11 @@ export const cloudRouter = os.router({
|
|||||||
try {
|
try {
|
||||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
logger.error(
|
||||||
|
'[ORPC] setAccountProxy error:',
|
||||||
|
extractErrorMessage(error),
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -204,7 +208,11 @@ export const cloudRouter = os.router({
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
logger.error(
|
||||||
|
'[ORPC] syncLocalAccount error:',
|
||||||
|
extractErrorMessage(error),
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
throw toSyncLocalAccountORPCError(error);
|
throw toSyncLocalAccountORPCError(error);
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -274,7 +282,11 @@ export const cloudRouter = os.router({
|
|||||||
try {
|
try {
|
||||||
return await exportCloudAccounts(input.stripTokens);
|
return await exportCloudAccounts(input.stripTokens);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
logger.error(
|
||||||
|
'[ORPC] exportCloudAccounts error:',
|
||||||
|
extractErrorMessage(error),
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
@@ -298,7 +310,11 @@ export const cloudRouter = os.router({
|
|||||||
try {
|
try {
|
||||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
logger.error(
|
||||||
|
'[ORPC] importCloudAccounts error:',
|
||||||
|
extractErrorMessage(error),
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -89,12 +89,8 @@ export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
|||||||
metadata_json TEXT
|
metadata_json TEXT
|
||||||
);
|
);
|
||||||
`);
|
`);
|
||||||
rawDb.exec(
|
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`);
|
||||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`);
|
||||||
);
|
|
||||||
rawDb.exec(
|
|
||||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -201,17 +197,30 @@ export class ProxyMetricsRepo {
|
|||||||
const reqRow = raw
|
const reqRow = raw
|
||||||
.prepare(
|
.prepare(
|
||||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||||
|
MIN(timestamp) as minTimestamp, MAX(timestamp) as maxTimestamp,
|
||||||
CASE WHEN COUNT(*) = 0 THEN 0
|
CASE WHEN COUNT(*) = 0 THEN 0
|
||||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||||
END as errorRate
|
END as errorRate
|
||||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||||
)
|
)
|
||||||
.get(cutoff) as
|
.get(cutoff) as
|
||||||
| { total: number; avgLatency: number | null; errorRate: number }
|
| {
|
||||||
|
total: number;
|
||||||
|
avgLatency: number | null;
|
||||||
|
errorRate: number;
|
||||||
|
minTimestamp: number | null;
|
||||||
|
maxTimestamp: number | null;
|
||||||
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|
||||||
const totalRequests = reqRow?.total ?? 0;
|
const totalRequests = reqRow?.total ?? 0;
|
||||||
const minutes = Math.max(windowMs / 60_000, 1);
|
let minutes = Math.max(windowMs / 60_000, 1);
|
||||||
|
|
||||||
|
if (reqRow?.minTimestamp && reqRow?.maxTimestamp && totalRequests > 0) {
|
||||||
|
const spanMs = reqRow.maxTimestamp - reqRow.minTimestamp;
|
||||||
|
minutes = Math.max(spanMs / 60_000, 1);
|
||||||
|
}
|
||||||
|
|
||||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||||
@@ -244,7 +253,9 @@ export class ProxyMetricsRepo {
|
|||||||
const { raw } = getProxyMetricsDb();
|
const { raw } = getProxyMetricsDb();
|
||||||
try {
|
try {
|
||||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
raw
|
||||||
|
.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`)
|
||||||
|
.run(cutoffTimestamp);
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Failed to delete old proxy metrics records', error);
|
logger.error('Failed to delete old proxy metrics records', error);
|
||||||
@@ -298,7 +309,10 @@ export class TrafficLogsRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
static getRecent(
|
||||||
|
limit: number = 50,
|
||||||
|
direction?: string,
|
||||||
|
): Array<{
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
direction: string;
|
direction: string;
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -349,9 +363,12 @@ export class TrafficLogsRepo {
|
|||||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||||
durationMs: r.duration_ms ?? undefined,
|
durationMs: r.duration_ms ?? undefined,
|
||||||
error:
|
error: r.error_message
|
||||||
r.error_message
|
? {
|
||||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
message: r.error_message,
|
||||||
|
stack: r.error_stack ?? undefined,
|
||||||
|
status: r.error_status ?? undefined,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||||
}));
|
}));
|
||||||
@@ -361,10 +378,18 @@ export class TrafficLogsRepo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
static getStats(): {
|
||||||
|
byDirection: Record<string, number>;
|
||||||
|
byEndpoint: Record<string, number>;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
const { raw } = getProxyMetricsDb();
|
const { raw } = getProxyMetricsDb();
|
||||||
try {
|
try {
|
||||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
const rows = raw
|
||||||
|
.prepare(
|
||||||
|
`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`,
|
||||||
|
)
|
||||||
|
.all() as Array<{
|
||||||
direction: string;
|
direction: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
cnt: number;
|
cnt: number;
|
||||||
@@ -400,7 +425,9 @@ export class TrafficLogsRepo {
|
|||||||
}> {
|
}> {
|
||||||
const { raw } = getProxyMetricsDb();
|
const { raw } = getProxyMetricsDb();
|
||||||
try {
|
try {
|
||||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
const rows = raw
|
||||||
|
.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`)
|
||||||
|
.all() as Array<{
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
direction: string;
|
direction: string;
|
||||||
request_id: string;
|
request_id: string;
|
||||||
@@ -427,9 +454,12 @@ export class TrafficLogsRepo {
|
|||||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||||
durationMs: r.duration_ms ?? undefined,
|
durationMs: r.duration_ms ?? undefined,
|
||||||
error:
|
error: r.error_message
|
||||||
r.error_message
|
? {
|
||||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
message: r.error_message,
|
||||||
|
stack: r.error_stack ?? undefined,
|
||||||
|
status: r.error_status ?? undefined,
|
||||||
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import {
|
import { bootstrapNestServer, isNestServerRunning, stopNestServer } from '../../server/main';
|
||||||
bootstrapNestServer,
|
|
||||||
isNestServerRunning,
|
|
||||||
stopNestServer,
|
|
||||||
} from '../../server/main';
|
|
||||||
import { ConfigManager } from '../config/manager';
|
import { ConfigManager } from '../config/manager';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -25,8 +21,6 @@ export async function closeGeminiNexus(): Promise<void> {
|
|||||||
await stopNestServer();
|
await stopNestServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Waits for the Gemini Nexus process to exit.
|
* Waits for the Gemini Nexus process to exit.
|
||||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import { logger } from '../../utils/logger';
|
import { logger } from '../../utils/logger';
|
||||||
import { getServiceOrThrow } from './service-registry';
|
import { getServiceOrThrow, getServiceOptional } from './service-registry';
|
||||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
import type {
|
||||||
|
IdeConfigResult,
|
||||||
|
SupportedIde,
|
||||||
|
} from '../../server/modules/proxy/proxy-ide-config.service';
|
||||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||||
|
import { ConfigManager } from '../config/manager';
|
||||||
|
import { networkInterfaces } from 'os';
|
||||||
|
|
||||||
export interface TrafficLogEntryParsed {
|
export interface TrafficLogEntryParsed {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
@@ -84,7 +89,15 @@ export async function exportTrafficLogs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CSV format
|
// CSV format
|
||||||
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
|
const headers = [
|
||||||
|
'timestamp',
|
||||||
|
'direction',
|
||||||
|
'requestId',
|
||||||
|
'endpoint',
|
||||||
|
'method',
|
||||||
|
'status',
|
||||||
|
'durationMs',
|
||||||
|
];
|
||||||
const rowsCsv = rows.map((e) =>
|
const rowsCsv = rows.map((e) =>
|
||||||
[
|
[
|
||||||
new Date(e.timestamp).toISOString(),
|
new Date(e.timestamp).toISOString(),
|
||||||
@@ -108,11 +121,27 @@ export async function exportTrafficLogs(
|
|||||||
|
|
||||||
export async function getParityCounters(): Promise<{
|
export async function getParityCounters(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
|
data?: {
|
||||||
|
totalRequests: number;
|
||||||
|
matchedRequests: number;
|
||||||
|
parityViolations: number;
|
||||||
|
lastUpdated: string;
|
||||||
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const tokenManager = getServiceOrThrow('tokenManager');
|
const tokenManager = getServiceOptional('tokenManager');
|
||||||
|
if (!tokenManager) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
totalRequests: 0,
|
||||||
|
matchedRequests: 0,
|
||||||
|
parityViolations: 0,
|
||||||
|
lastUpdated: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: tokenManager.getParityCounters(),
|
data: tokenManager.getParityCounters(),
|
||||||
@@ -124,14 +153,24 @@ export async function getParityCounters(): Promise<{
|
|||||||
|
|
||||||
export async function getCircuitBreakerStatus(): Promise<{
|
export async function getCircuitBreakerStatus(): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
|
data?: {
|
||||||
|
states: Record<string, { state: string; failures: number; lastFailure: string | null }>;
|
||||||
|
globalEnabled: boolean;
|
||||||
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const tokenManager = getServiceOrThrow('tokenManager');
|
const tokenManager = getServiceOptional('tokenManager');
|
||||||
|
if (!tokenManager) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { states: {}, globalEnabled: true },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> =
|
||||||
|
{};
|
||||||
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
||||||
states[accountId] = {
|
states[accountId] = {
|
||||||
state: entry.state,
|
state: entry.state,
|
||||||
@@ -166,7 +205,21 @@ export async function getProxyMetrics(): Promise<{
|
|||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const proxyMetrics = getServiceOrThrow('proxyMetrics');
|
const proxyMetrics = getServiceOptional('proxyMetrics');
|
||||||
|
if (!proxyMetrics) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
uptimeSeconds: 0,
|
||||||
|
totalRequests: 0,
|
||||||
|
activeConnections: 0,
|
||||||
|
avgLatency: 0,
|
||||||
|
errorRate: 0,
|
||||||
|
requestsPerMinute: 0,
|
||||||
|
cacheHitRate: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const metrics = proxyMetrics.getMetrics();
|
const metrics = proxyMetrics.getMetrics();
|
||||||
return {
|
return {
|
||||||
@@ -187,9 +240,14 @@ export async function getProxyMetrics(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
export async function getRecentRequests(): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: unknown[];
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
const proxyReplay = getServiceOptional('proxyReplay');
|
||||||
|
if (!proxyReplay) return { success: true, data: [] };
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
data: proxyReplay.getRecentRequests(20),
|
data: proxyReplay.getRecentRequests(20),
|
||||||
@@ -201,7 +259,11 @@ export async function getRecentRequests(): Promise<{ success: boolean; data?: un
|
|||||||
|
|
||||||
export async function replayRequest(
|
export async function replayRequest(
|
||||||
requestId: string,
|
requestId: string,
|
||||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
data?: { original: unknown; newResponse: unknown };
|
||||||
|
error?: string;
|
||||||
|
}> {
|
||||||
try {
|
try {
|
||||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||||
const result = await proxyReplay.replayRequest(requestId);
|
const result = await proxyReplay.replayRequest(requestId);
|
||||||
@@ -278,14 +340,14 @@ export async function getModelCapabilities(): Promise<{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapIdeConfigResult(
|
function mapIdeConfigResult(result: IdeConfigResult): {
|
||||||
result: IdeConfigResult,
|
|
||||||
): {
|
|
||||||
name: string;
|
name: string;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
proxySetting: string;
|
proxySetting?: string;
|
||||||
apiKeySetting: string;
|
apiKeySetting?: string;
|
||||||
instructions: string;
|
instructions?: string;
|
||||||
|
format: 'json' | 'shell' | 'env' | 'instructions';
|
||||||
|
content: Record<string, unknown> | string;
|
||||||
} {
|
} {
|
||||||
switch (result.ide) {
|
switch (result.ide) {
|
||||||
case 'vscode':
|
case 'vscode':
|
||||||
@@ -295,6 +357,8 @@ function mapIdeConfigResult(
|
|||||||
proxySetting: 'openai.url',
|
proxySetting: 'openai.url',
|
||||||
apiKeySetting: 'openai.apiKey',
|
apiKeySetting: 'openai.apiKey',
|
||||||
instructions: 'Add these settings to your VS Code settings.json',
|
instructions: 'Add these settings to your VS Code settings.json',
|
||||||
|
format: result.format,
|
||||||
|
content: result.content as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
case 'cursor':
|
case 'cursor':
|
||||||
return {
|
return {
|
||||||
@@ -303,6 +367,8 @@ function mapIdeConfigResult(
|
|||||||
proxySetting: 'OPENAI_BASE_URL',
|
proxySetting: 'OPENAI_BASE_URL',
|
||||||
apiKeySetting: 'OPENAI_API_KEY',
|
apiKeySetting: 'OPENAI_API_KEY',
|
||||||
instructions: 'Set these environment variables for Cursor',
|
instructions: 'Set these environment variables for Cursor',
|
||||||
|
format: result.format,
|
||||||
|
content: result.content as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
case 'claude-code':
|
case 'claude-code':
|
||||||
return {
|
return {
|
||||||
@@ -311,6 +377,8 @@ function mapIdeConfigResult(
|
|||||||
proxySetting: 'ANTHROPIC_BASE_URL',
|
proxySetting: 'ANTHROPIC_BASE_URL',
|
||||||
apiKeySetting: 'ANTHROPIC_API_KEY',
|
apiKeySetting: 'ANTHROPIC_API_KEY',
|
||||||
instructions: 'Run these export commands before starting Claude Code',
|
instructions: 'Run these export commands before starting Claude Code',
|
||||||
|
format: result.format,
|
||||||
|
content: result.content as string,
|
||||||
};
|
};
|
||||||
case 'jetbrains':
|
case 'jetbrains':
|
||||||
return {
|
return {
|
||||||
@@ -319,6 +387,8 @@ function mapIdeConfigResult(
|
|||||||
proxySetting: 'Base URL',
|
proxySetting: 'Base URL',
|
||||||
apiKeySetting: 'API Key',
|
apiKeySetting: 'API Key',
|
||||||
instructions: String(result.content),
|
instructions: String(result.content),
|
||||||
|
format: result.format,
|
||||||
|
content: result.content as string,
|
||||||
};
|
};
|
||||||
case 'opencide':
|
case 'opencide':
|
||||||
return {
|
return {
|
||||||
@@ -327,9 +397,19 @@ function mapIdeConfigResult(
|
|||||||
proxySetting: 'OPENAI_BASE_URL',
|
proxySetting: 'OPENAI_BASE_URL',
|
||||||
apiKeySetting: 'OPENAI_API_KEY',
|
apiKeySetting: 'OPENAI_API_KEY',
|
||||||
instructions: 'Add these lines to your .env file',
|
instructions: 'Add these lines to your .env file',
|
||||||
|
format: result.format,
|
||||||
|
content: result.content as string,
|
||||||
|
};
|
||||||
|
case 'opencode':
|
||||||
|
return {
|
||||||
|
name: 'OpenCode',
|
||||||
|
settingsPath: '.config/opencode/providers.json',
|
||||||
|
instructions: 'Add this snippet to your providers.json',
|
||||||
|
format: result.format,
|
||||||
|
content: result.content as Record<string, unknown>,
|
||||||
};
|
};
|
||||||
default: {
|
default: {
|
||||||
const exhaustiveCheck: never = result.ide;
|
const exhaustiveCheck: never = result.ide as never;
|
||||||
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,29 +417,121 @@ function mapIdeConfigResult(
|
|||||||
|
|
||||||
export async function generateIdeConfig(
|
export async function generateIdeConfig(
|
||||||
ide: string,
|
ide: string,
|
||||||
|
defaultModel?: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
success: boolean;
|
success: boolean;
|
||||||
data?: {
|
data?: {
|
||||||
name: string;
|
name: string;
|
||||||
settingsPath: string;
|
settingsPath: string;
|
||||||
proxySetting: string;
|
proxySetting?: string;
|
||||||
apiKeySetting: string;
|
apiKeySetting?: string;
|
||||||
instructions: string;
|
instructions?: string;
|
||||||
|
format: 'json' | 'shell' | 'env' | 'instructions';
|
||||||
|
content: Record<string, unknown> | string;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
|
const supportedIdes = [
|
||||||
|
'cursor',
|
||||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
'vscode',
|
||||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
'claude-code',
|
||||||
|
'jetbrains',
|
||||||
|
'opencide',
|
||||||
|
'opencode',
|
||||||
|
] as const;
|
||||||
|
const normalizedIde = ide.toLowerCase() as (typeof supportedIdes)[number];
|
||||||
if (!supportedIdes.includes(normalizedIde)) {
|
if (!supportedIdes.includes(normalizedIde)) {
|
||||||
return { success: false, error: `Unsupported IDE: ${ide}` };
|
return { success: false, error: `Unsupported IDE: ${ide}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
|
// Attempt to use NestJS service if running, fallback to manual generation
|
||||||
if (!result) {
|
const proxyIdeConfig = getServiceOptional('proxyIdeConfig');
|
||||||
return { success: false, error: 'Failed to generate IDE config: server not configured' };
|
if (proxyIdeConfig) {
|
||||||
|
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde, defaultModel);
|
||||||
|
if (result) return { success: true, data: mapIdeConfigResult(result) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback logic when proxy is stopped
|
||||||
|
const config = ConfigManager.getCachedConfig()?.proxy;
|
||||||
|
const port = config?.port || 8045;
|
||||||
|
const apiKey = config?.api_key || 'YOUR_API_KEY';
|
||||||
|
|
||||||
|
let localIp = '127.0.0.1';
|
||||||
|
const interfaces = networkInterfaces();
|
||||||
|
for (const iface of Object.values(interfaces)) {
|
||||||
|
if (iface) {
|
||||||
|
for (const entry of iface) {
|
||||||
|
if (entry.family === 'IPv4' && !entry.internal) {
|
||||||
|
localIp = entry.address;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback generator mimicking the proxyIdeConfig.service
|
||||||
|
const baseUrl = `http://${localIp}:${port}/v1`;
|
||||||
|
let result: IdeConfigResult;
|
||||||
|
|
||||||
|
switch (normalizedIde) {
|
||||||
|
case 'cursor':
|
||||||
|
result = {
|
||||||
|
ide: normalizedIde,
|
||||||
|
format: 'json',
|
||||||
|
content: { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl },
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'vscode':
|
||||||
|
result = {
|
||||||
|
ide: normalizedIde,
|
||||||
|
format: 'json',
|
||||||
|
content: { 'openai.url': baseUrl, 'openai.apiKey': apiKey },
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'claude-code':
|
||||||
|
result = {
|
||||||
|
ide: normalizedIde,
|
||||||
|
format: 'shell',
|
||||||
|
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'jetbrains':
|
||||||
|
result = {
|
||||||
|
ide: normalizedIde,
|
||||||
|
format: 'instructions',
|
||||||
|
content: [
|
||||||
|
'JetBrains AI Assistant Setup:',
|
||||||
|
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
|
||||||
|
'2. Navigate to Languages & Frameworks > AI Assistant.',
|
||||||
|
'3. Under "Custom OpenAI Endpoint", set:',
|
||||||
|
` - Base URL: ${baseUrl}`,
|
||||||
|
` - API Key: ${apiKey}`,
|
||||||
|
'4. Click "Apply" and restart the IDE if prompted.',
|
||||||
|
].join('\n'),
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'opencide':
|
||||||
|
result = {
|
||||||
|
ide: normalizedIde,
|
||||||
|
format: 'env',
|
||||||
|
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
case 'opencode':
|
||||||
|
result = {
|
||||||
|
ide: normalizedIde,
|
||||||
|
format: 'json',
|
||||||
|
content: {
|
||||||
|
apiType: 'openai',
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: baseUrl,
|
||||||
|
models: { chat: [defaultModel || 'gemini-3.1-pro-high'] },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new Error('Unsupported');
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -155,9 +155,11 @@ const IdeConfigResponseSchema = z.object({
|
|||||||
.object({
|
.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
settingsPath: z.string(),
|
settingsPath: z.string(),
|
||||||
proxySetting: z.string(),
|
proxySetting: z.string().optional(),
|
||||||
apiKeySetting: z.string(),
|
apiKeySetting: z.string().optional(),
|
||||||
instructions: z.string(),
|
instructions: z.string().optional(),
|
||||||
|
format: z.enum(['json', 'shell', 'env', 'instructions']).optional(),
|
||||||
|
content: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
error: z.string().optional(),
|
error: z.string().optional(),
|
||||||
@@ -204,10 +206,10 @@ export const proxyAdvancedRouter = os.router({
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
generateIdeConfig: os
|
generateIdeConfig: os
|
||||||
.input(z.object({ ide: z.string() }))
|
.input(z.object({ ide: z.string(), defaultModel: z.string().optional() }))
|
||||||
.output(IdeConfigResponseSchema)
|
.output(IdeConfigResponseSchema)
|
||||||
.handler(async ({ input }) => {
|
.handler(async ({ input }) => {
|
||||||
return generateIdeConfig(input.ide);
|
return generateIdeConfig(input.ide, input.defaultModel);
|
||||||
}),
|
}),
|
||||||
|
|
||||||
replayRequest: os
|
replayRequest: os
|
||||||
|
|||||||
@@ -37,3 +37,9 @@ export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
|
|||||||
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||||
return svc;
|
return svc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getServiceOptional<K extends keyof ProxyAdvancedRegistry>(
|
||||||
|
key: K,
|
||||||
|
): ProxyAdvancedRegistry[K] {
|
||||||
|
return proxyAdvancedRegistry[key];
|
||||||
|
}
|
||||||
+18
-107
@@ -1,19 +1,10 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { StatusBar } from '@/components/StatusBar';
|
import { StatusBar } from '@/components/StatusBar';
|
||||||
import {
|
import { LayoutDashboard, Settings, Network, RefreshCw, BarChart3 } from 'lucide-react';
|
||||||
LayoutDashboard,
|
|
||||||
Settings,
|
|
||||||
Network,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
RefreshCw,
|
|
||||||
BarChart3,
|
|
||||||
} 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';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
||||||
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';
|
||||||
@@ -25,17 +16,6 @@ export const MainLayout: React.FC = () => {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const hasShownRouteErrorToastRef = useRef(false);
|
const hasShownRouteErrorToastRef = useRef(false);
|
||||||
|
|
||||||
// Initialize state from localStorage if available, default to false (expanded)
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
|
||||||
const saved = localStorage.getItem('sidebar-collapsed');
|
|
||||||
return saved ? JSON.parse(saved) : false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Persist state changes
|
|
||||||
useEffect(() => {
|
|
||||||
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
|
|
||||||
}, [isCollapsed]);
|
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{
|
{
|
||||||
to: '/',
|
to: '/',
|
||||||
@@ -61,112 +41,44 @@ export const MainLayout: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||||
<div className="flex flex-1 overflow-hidden">
|
<header className="flex h-12 items-center border-b border-white/[0.06] px-4">
|
||||||
{/* Sidebar */}
|
<div className="flex shrink-0 items-center gap-2.5">
|
||||||
<aside
|
|
||||||
className={cn(
|
|
||||||
'relative flex flex-col border-r border-white/[0.06] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
|
||||||
isCollapsed ? 'w-[60px]' : 'w-56',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{/* Collapse toggle */}
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
'absolute -right-3 top-7 z-10 flex h-6 w-6 items-center justify-center rounded-full',
|
|
||||||
'bg-muted border border-white/[0.1] text-muted-foreground',
|
|
||||||
'opacity-0 transition-all duration-200 hover:bg-white/[0.1] hover:text-foreground',
|
|
||||||
'group-hover:opacity-100',
|
|
||||||
)}
|
|
||||||
style={{ opacity: 0 }}
|
|
||||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
|
||||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0')}
|
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
|
||||||
>
|
|
||||||
{isCollapsed ? (
|
|
||||||
<ChevronRight className="h-3 w-3" />
|
|
||||||
) : (
|
|
||||||
<ChevronLeft className="h-3 w-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Brand */}
|
|
||||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
|
|
||||||
<div className="flex items-center gap-2.5 overflow-hidden whitespace-nowrap">
|
|
||||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
||||||
<img
|
<img src={iconPng} alt="Gemini Nexus Logo" className="h-full w-full object-contain" />
|
||||||
src={iconPng}
|
|
||||||
alt="Gemini Nexus Logo"
|
|
||||||
className="h-full w-full object-contain"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'overflow-hidden transition-all duration-300',
|
|
||||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h1 className="text-[15px] font-semibold tracking-tight whitespace-nowrap">
|
||||||
|
Gemini Nexus
|
||||||
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
<nav className="mx-6 flex flex-1 items-center justify-center gap-1 overflow-x-auto">
|
||||||
<nav className="flex-1 space-y-1 px-2 pt-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = location.pathname === item.to;
|
const isActive = location.pathname === item.to;
|
||||||
|
|
||||||
if (isCollapsed) {
|
|
||||||
return (
|
|
||||||
<Tooltip key={item.to} delayDuration={0}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Link
|
|
||||||
to={item.to}
|
|
||||||
className={cn(
|
|
||||||
'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
|
|
||||||
isActive
|
|
||||||
? 'bg-white/[0.08] text-foreground'
|
|
||||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<item.icon className="h-[18px] w-[18px]" />
|
|
||||||
<span className="sr-only">{item.label}</span>
|
|
||||||
</Link>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right" className="text-xs">
|
|
||||||
{item.label}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
|
'flex items-center gap-2 rounded-md px-3 py-2 text-[13px] font-medium whitespace-nowrap transition-colors',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-white/[0.08] text-foreground'
|
? 'text-foreground bg-white/[0.08]'
|
||||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.04]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-[18px] w-[18px]" />
|
<item.icon className="h-[16px] w-[16px]" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</TooltipProvider>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Status bar */}
|
<div className="shrink-0">
|
||||||
<div className="border-t border-white/[0.06] p-2">
|
<StatusBar />
|
||||||
<StatusBar isCollapsed={isCollapsed} />
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</header>
|
||||||
|
|
||||||
{/* Content Area */}
|
<main className="flex-1 overflow-auto">
|
||||||
<main className="flex-1 overflow-auto transition-all duration-300">
|
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
resetKeys={[location.pathname]}
|
resetKeys={[location.pathname]}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
@@ -186,7 +98,7 @@ 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-xl border border-white/[0.06] border-dashed p-8 text-center">
|
<div className="rounded-xl border border-dashed border-white/[0.06] p-8 text-center">
|
||||||
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
||||||
<div className="text-muted-foreground mt-2 text-xs">{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}>
|
||||||
@@ -201,6 +113,5 @@ export const MainLayout: React.FC = () => {
|
|||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -62,7 +62,9 @@ export class QuotaService {
|
|||||||
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
|
logger.error(
|
||||||
|
`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [undefined, undefined];
|
return [undefined, undefined];
|
||||||
@@ -174,7 +176,9 @@ 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 instanceof Error ? error.message : String(error)}`);
|
logger.warn(
|
||||||
|
`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
);
|
||||||
lastError = error instanceof Error ? error : new Error(String(error));
|
lastError = error instanceof Error ? error : new Error(String(error));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +186,12 @@ export class QuotaService {
|
|||||||
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
|
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 instanceof Error ? error.message : String(error)}`);
|
throw (
|
||||||
|
lastError ??
|
||||||
|
new Error(
|
||||||
|
`Quota query failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||||
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const nivoTheme: PartialTheme = {
|
|||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Inter, sans-serif",
|
fontFamily: 'Inter, sans-serif',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fill: '#6b7280',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
@@ -45,7 +45,7 @@ export const nivoTheme: PartialTheme = {
|
|||||||
},
|
},
|
||||||
legends: {
|
legends: {
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Inter, sans-serif",
|
fontFamily: 'Inter, sans-serif',
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fill: '#6b7280',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
@@ -54,7 +54,7 @@ export const nivoTheme: PartialTheme = {
|
|||||||
container: {
|
container: {
|
||||||
background: '#16161e',
|
background: '#16161e',
|
||||||
color: '#e8eaed',
|
color: '#e8eaed',
|
||||||
fontFamily: "Inter, sans-serif",
|
fontFamily: 'Inter, sans-serif',
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
border: '1px solid rgba(255,255,255,0.06)',
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
|||||||
+11
-6
@@ -30,6 +30,12 @@ import { ConfigManager } from './ipc/config/manager';
|
|||||||
import { AppConfig } from './types/config';
|
import { AppConfig } from './types/config';
|
||||||
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
|
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
|
||||||
import { safeStringifyPacket } from './utils/sensitiveDataMasking';
|
import { safeStringifyPacket } from './utils/sensitiveDataMasking';
|
||||||
|
import { IS_DEV_ENVIRONMENT } from './utils/paths';
|
||||||
|
|
||||||
|
// Set app name early so Electron's userData path reflects the environment
|
||||||
|
if (IS_DEV_ENVIRONMENT) {
|
||||||
|
app.setName('Gemini Nexus Dev');
|
||||||
|
}
|
||||||
|
|
||||||
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
|
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ if (squirrelStartup) {
|
|||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
const inDevelopment = IS_DEV_ENVIRONMENT;
|
||||||
const debugHttpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
|
const debugHttpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
|
||||||
const debugHttpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
|
const debugHttpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
|
||||||
const debugNoProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
|
const debugNoProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
|
||||||
@@ -116,6 +122,8 @@ function isRunningFromExpectedInstallDir() {
|
|||||||
localAppData: process.env.LOCALAPPDATA,
|
localAppData: process.env.LOCALAPPDATA,
|
||||||
appName: app.getName(),
|
appName: app.getName(),
|
||||||
execPath: process.execPath,
|
execPath: process.execPath,
|
||||||
|
programFiles: process.env.ProgramFiles,
|
||||||
|
programFilesX86: process.env['ProgramFiles(x86)'],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,6 +148,7 @@ function showWindowsInstallNoticeIfNeeded() {
|
|||||||
hasShownInstallNotice = true;
|
hasShownInstallNotice = true;
|
||||||
const language = resolveInstallNoticeLanguage({
|
const language = resolveInstallNoticeLanguage({
|
||||||
configLanguage: startupConfig?.language,
|
configLanguage: startupConfig?.language,
|
||||||
|
preferredLanguages: app.getPreferredSystemLanguages(),
|
||||||
locale: app.getLocale(),
|
locale: app.getLocale(),
|
||||||
});
|
});
|
||||||
const text = getInstallNoticeText(language);
|
const text = getInstallNoticeText(language);
|
||||||
@@ -164,11 +173,7 @@ function showWindowsInstallNoticeIfNeeded() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
const isDev = IS_DEV_ENVIRONMENT;
|
||||||
|
|
||||||
if (isDev) {
|
|
||||||
app.setName('Gemini Nexus Dev');
|
|
||||||
}
|
|
||||||
|
|
||||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||||
|
|
||||||
|
|||||||
+5
-6
@@ -10,16 +10,15 @@ import os from 'os';
|
|||||||
let sentryEnabled = false;
|
let sentryEnabled = false;
|
||||||
try {
|
try {
|
||||||
const home = os.homedir();
|
const home = os.homedir();
|
||||||
|
const isDev = process.env.NODE_ENV === 'development';
|
||||||
|
const appName = isDev ? 'Gemini Nexus Dev' : 'Gemini Nexus';
|
||||||
let appDataPath = '';
|
let appDataPath = '';
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
appDataPath = path.join(
|
appDataPath = path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), appName);
|
||||||
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
|
||||||
'Gemini Nexus',
|
|
||||||
);
|
|
||||||
} else if (process.platform === 'darwin') {
|
} else if (process.platform === 'darwin') {
|
||||||
appDataPath = path.join(home, 'Library', 'Application Support', 'Gemini Nexus');
|
appDataPath = path.join(home, 'Library', 'Application Support', appName);
|
||||||
} else {
|
} else {
|
||||||
appDataPath = path.join(home, '.config', 'Gemini Nexus');
|
appDataPath = path.join(home, '.config', appName);
|
||||||
}
|
}
|
||||||
|
|
||||||
const configPath = path.join(appDataPath, 'gui_config.json');
|
const configPath = path.join(appDataPath, 'gui_config.json');
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
function HomePage() {
|
function HomePage() {
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl p-6">
|
<div className="container mx-auto max-w-6xl p-6">
|
||||||
<CloudAccountList />
|
<CloudAccountList />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
+81
-40
@@ -26,7 +26,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Loader2, Copy, Code, Terminal, Eye, EyeOff, ServerOff, ChevronDown, ChevronUp } from 'lucide-react';
|
import {
|
||||||
|
Loader2,
|
||||||
|
Copy,
|
||||||
|
Code,
|
||||||
|
Terminal,
|
||||||
|
Eye,
|
||||||
|
EyeOff,
|
||||||
|
ServerOff,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronUp,
|
||||||
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -37,7 +47,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 { ToolsTab } from '@/components/proxy/advanced/ToolsTab';
|
||||||
import { flatMap, sortBy, uniq } from 'lodash-es';
|
import { flatMap, sortBy, uniq } from 'lodash-es';
|
||||||
|
|
||||||
type ProxyProtocol = 'openai' | 'anthropic';
|
type ProxyProtocol = 'openai' | 'anthropic';
|
||||||
@@ -304,14 +314,14 @@ print(response.choices[0].message.content)`;
|
|||||||
<TabsList className="grid w-full grid-cols-4">
|
<TabsList className="grid w-full grid-cols-4">
|
||||||
<TabsTrigger value="status">Status</TabsTrigger>
|
<TabsTrigger value="status">Status</TabsTrigger>
|
||||||
<TabsTrigger value="routing">Routing</TabsTrigger>
|
<TabsTrigger value="routing">Routing</TabsTrigger>
|
||||||
|
<TabsTrigger value="tools">Tools</TabsTrigger>
|
||||||
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
|
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
|
||||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Status Tab */}
|
{/* Status Tab */}
|
||||||
<TabsContent value="status" className="space-y-5">
|
<TabsContent value="status" className="space-y-5">
|
||||||
{/* Service Control Card */}
|
{/* Service Control Card */}
|
||||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -411,7 +421,11 @@ print(response.choices[0].message.content)`;
|
|||||||
<Copy size={14} className="mr-1" />
|
<Copy size={14} className="mr-1" />
|
||||||
{t('proxy.copy')}
|
{t('proxy.copy')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsRegenerateDialogOpen(true)}
|
||||||
|
>
|
||||||
{t('proxy.regenerate')}
|
{t('proxy.regenerate')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,7 +434,7 @@ print(response.choices[0].message.content)`;
|
|||||||
<Badge variant="destructive">
|
<Badge variant="destructive">
|
||||||
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
|
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
|
||||||
</Badge>
|
</Badge>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-muted-foreground text-xs">
|
||||||
{proxyConfig.api_key
|
{proxyConfig.api_key
|
||||||
? 'API key is too short for secure operation'
|
? 'API key is too short for secure operation'
|
||||||
: 'Generate or set an API key to secure the proxy'}
|
: 'Generate or set an API key to secure the proxy'}
|
||||||
@@ -431,7 +445,9 @@ print(response.choices[0].message.content)`;
|
|||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
|
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
|
||||||
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
|
<DialogDescription>
|
||||||
|
{t('proxy.regenerateConfirm.description')}
|
||||||
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
|
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
|
||||||
@@ -457,7 +473,9 @@ print(response.choices[0].message.content)`;
|
|||||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
|
<div className="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-muted-foreground">{t('proxy.config.auto_start_desc')}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('proxy.config.auto_start_desc')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={proxyConfig.auto_start}
|
checked={proxyConfig.auto_start}
|
||||||
@@ -470,7 +488,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Account Quota Context */}
|
{/* Account Quota Context */}
|
||||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Account Pool</CardTitle>
|
<CardTitle>Account Pool</CardTitle>
|
||||||
<CardDescription>Quota and routing context</CardDescription>
|
<CardDescription>Quota and routing context</CardDescription>
|
||||||
@@ -481,10 +499,14 @@ print(response.choices[0].message.content)`;
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-[13px] font-medium">Primary Account</p>
|
<p className="text-[13px] font-medium">Primary Account</p>
|
||||||
<p className="text-muted-foreground font-mono text-[13px]">{accounts[0].email}</p>
|
<p className="text-muted-foreground font-mono text-[13px]">
|
||||||
|
{accounts[0].email}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{accounts.length > 1 && (
|
{accounts.length > 1 && (
|
||||||
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
|
<Badge variant="outline">
|
||||||
|
Pool Mode: Active ({accounts.length} accounts)
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" className="text-xs" asChild>
|
<Button variant="outline" size="sm" className="text-xs" asChild>
|
||||||
@@ -516,9 +538,7 @@ print(response.choices[0].message.content)`;
|
|||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyToClipboard(
|
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
|
||||||
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy size={14} />
|
<Copy size={14} />
|
||||||
@@ -572,9 +592,14 @@ print(response.choices[0].message.content)`;
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return items.map((metric) => (
|
return items.map((metric) => (
|
||||||
<Card key={metric.label} className="border border-white/[0.06] bg-card shadow-none">
|
<Card
|
||||||
|
key={metric.label}
|
||||||
|
className="bg-card border border-white/[0.06] shadow-none"
|
||||||
|
>
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">{metric.label}</p>
|
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||||
|
{metric.label}
|
||||||
|
</p>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span className="font-mono text-xl font-semibold tabular-nums">
|
<span className="font-mono text-xl font-semibold tabular-nums">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
@@ -592,7 +617,9 @@ print(response.choices[0].message.content)`;
|
|||||||
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
|
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-base font-semibold">Proxy is not running</p>
|
<p className="text-base font-semibold">Proxy is not running</p>
|
||||||
<p className="text-muted-foreground text-[13px]">Start the proxy to enable local API access</p>
|
<p className="text-muted-foreground text-[13px]">
|
||||||
|
Start the proxy to enable local API access
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -609,19 +636,19 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* Routing Tab */}
|
{/* Routing Tab */}
|
||||||
<TabsContent value="routing" className="space-y-5">
|
<TabsContent value="routing" className="space-y-5">
|
||||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||||
<CardHeader>
|
<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-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
|
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
|
||||||
<div>Requested Model</div>
|
<div>Requested Model</div>
|
||||||
<div>Resolved Model</div>
|
<div>Resolved Model</div>
|
||||||
<div>Provider</div>
|
<div>Provider</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 items-center p-3 text-[13px]">
|
<div className="grid grid-cols-3 items-center gap-4 p-3 text-[13px]">
|
||||||
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
|
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
|
||||||
<Select
|
<Select
|
||||||
value={resolveAnthropicMappingValue(
|
value={resolveAnthropicMappingValue(
|
||||||
@@ -649,7 +676,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Select>
|
</Select>
|
||||||
<Badge variant="secondary">Anthropic</Badge>
|
<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]">
|
<div className="grid grid-cols-3 items-center gap-4 border-t border-white/[0.06] p-3 text-[13px]">
|
||||||
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
|
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
|
||||||
<Select
|
<Select
|
||||||
value={resolveAnthropicMappingValue(
|
value={resolveAnthropicMappingValue(
|
||||||
@@ -696,7 +723,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Current Mappings */}
|
{/* Current Mappings */}
|
||||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
||||||
@@ -706,10 +733,12 @@ print(response.choices[0].message.content)`;
|
|||||||
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
||||||
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
||||||
return mappingEntries.length === 0 && customEntries.length === 0 ? (
|
return mappingEntries.length === 0 && customEntries.length === 0 ? (
|
||||||
<p className="text-[13px] text-muted-foreground">{t('proxy.routing.noMapping')}</p>
|
<p className="text-muted-foreground text-[13px]">
|
||||||
|
{t('proxy.routing.noMapping')}
|
||||||
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
|
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
|
||||||
<div>{t('proxy.mapping.maps_to')}</div>
|
<div>{t('proxy.mapping.maps_to')}</div>
|
||||||
<div>{t('settings.modelMapping.targetGemini')}</div>
|
<div>{t('settings.modelMapping.targetGemini')}</div>
|
||||||
<div>Provider</div>
|
<div>Provider</div>
|
||||||
@@ -717,7 +746,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{mappingEntries.map(([key, value]) => (
|
{mappingEntries.map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
|
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
|
||||||
>
|
>
|
||||||
<div className="font-mono text-xs">{key}</div>
|
<div className="font-mono text-xs">{key}</div>
|
||||||
<div className="font-mono text-xs">{value}</div>
|
<div className="font-mono text-xs">{value}</div>
|
||||||
@@ -727,7 +756,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{customEntries.map(([key, value]) => (
|
{customEntries.map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
|
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
|
||||||
>
|
>
|
||||||
<div className="font-mono text-xs">{key}</div>
|
<div className="font-mono text-xs">{key}</div>
|
||||||
<div className="font-mono text-xs">{value}</div>
|
<div className="font-mono text-xs">{value}</div>
|
||||||
@@ -741,22 +770,30 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Upstream Proxy Status */}
|
{/* Upstream Proxy Status */}
|
||||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
||||||
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.status')}</span>
|
<span className="text-muted-foreground text-[13px]">
|
||||||
|
{t('proxy.routing.status')}
|
||||||
|
</span>
|
||||||
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
|
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
|
||||||
{proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')}
|
{proxyConfig.upstream_proxy?.enabled
|
||||||
|
? t('proxy.routing.enabled')
|
||||||
|
: t('proxy.routing.disabled')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{proxyConfig.upstream_proxy?.enabled && (
|
{proxyConfig.upstream_proxy?.enabled && (
|
||||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
<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="text-muted-foreground text-[13px]">
|
||||||
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
|
{t('proxy.routing.url')}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{proxyConfig.upstream_proxy?.url || '—'}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -765,7 +802,7 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* API Docs Tab */}
|
{/* API Docs Tab */}
|
||||||
<TabsContent value="api-docs" className="space-y-5">
|
<TabsContent value="api-docs" className="space-y-5">
|
||||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Code size={20} />
|
<Code size={20} />
|
||||||
@@ -812,7 +849,11 @@ print(response.choices[0].message.content)`;
|
|||||||
{provider}
|
{provider}
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{models.map((modelId) => (
|
{models.map((modelId) => (
|
||||||
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
|
<SelectItem
|
||||||
|
key={modelId}
|
||||||
|
value={modelId}
|
||||||
|
className="font-mono text-xs"
|
||||||
|
>
|
||||||
{modelId}
|
{modelId}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -853,7 +894,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* cURL */}
|
{/* cURL */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
|
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
|
||||||
onClick={() => setShowCurl(!showCurl)}
|
onClick={() => setShowCurl(!showCurl)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -876,7 +917,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCurl && (
|
{showCurl && (
|
||||||
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||||
{getCurlExample(activeModelTab)}
|
{getCurlExample(activeModelTab)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -885,7 +926,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* Python */}
|
{/* Python */}
|
||||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
|
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
|
||||||
onClick={() => setShowPython(!showPython)}
|
onClick={() => setShowPython(!showPython)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -908,7 +949,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showPython && (
|
{showPython && (
|
||||||
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||||
{getPythonExample(activeModelTab)}
|
{getPythonExample(activeModelTab)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -918,9 +959,9 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
{/* Advanced Tab */}
|
{/* Tools Tab */}
|
||||||
<TabsContent value="advanced">
|
<TabsContent value="tools">
|
||||||
<ProxyAdvancedTab proxyConfig={proxyConfig} />
|
<ToolsTab availableModelIds={availableModelIds} />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+15
-11
@@ -92,7 +92,7 @@ function SettingsPage() {
|
|||||||
|
|
||||||
{/* --- GENERAL TAB --- */}
|
{/* --- GENERAL TAB --- */}
|
||||||
<TabsContent value="general" className="space-y-5">
|
<TabsContent value="general" className="space-y-5">
|
||||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
@@ -139,7 +139,7 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Account Settings Card */}
|
{/* Account Settings Card */}
|
||||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
@@ -149,7 +149,9 @@ 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-muted-foreground text-xs">{t('settings.account.auto_refresh_desc')}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('settings.account.auto_refresh_desc')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.auto_refresh || false}
|
checked={config?.auto_refresh || false}
|
||||||
@@ -165,7 +167,9 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="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-muted-foreground text-xs">{t('settings.account.auto_sync_desc')}</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
|
{t('settings.account.auto_sync_desc')}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.auto_sync || false}
|
checked={config?.auto_sync || false}
|
||||||
@@ -180,7 +184,7 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isAutoStartSupported && (
|
{isAutoStartSupported && (
|
||||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
@@ -211,7 +215,7 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
@@ -230,7 +234,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-accent hover:text-accent/80 transition-colors"
|
className="text-accent hover:text-accent/80 flex items-center gap-2 font-medium transition-colors"
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
<span>{t('settings.openLogDir')}</span>
|
<span>{t('settings.openLogDir')}</span>
|
||||||
@@ -240,7 +244,7 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Privacy & Error Reporting Card */}
|
{/* Privacy & Error Reporting Card */}
|
||||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
@@ -267,7 +271,7 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Notifications Card */}
|
{/* Notifications Card */}
|
||||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
@@ -327,7 +331,7 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-16 rounded-lg border border-white/[0.08] bg-muted/50 px-2 py-1 text-center text-[13px] text-foreground focus:outline-none focus:border-accent/50"
|
className="bg-muted/50 text-foreground focus:border-accent/50 w-16 rounded-lg border border-white/[0.08] px-2 py-1 text-center text-[13px] focus:outline-none"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">%</span>
|
<span className="text-muted-foreground text-sm">%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,7 +347,7 @@ 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 className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||||
<CardHeader className="px-0 pt-4">
|
<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>
|
||||||
|
|||||||
+233
-171
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
|||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ResponsiveLine } from '@nivo/line';
|
import { ResponsiveLine } from '@nivo/line';
|
||||||
import { ResponsiveBar } from '@nivo/bar';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -10,18 +9,23 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||||
|
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||||
import { StatCard } from '@/components/usage/StatCard';
|
import { StatCard } from '@/components/usage/StatCard';
|
||||||
import { ChartCard } from '@/components/usage/ChartCard';
|
import { ChartCard } from '@/components/usage/ChartCard';
|
||||||
import {
|
import { ForecastCard } from '@/components/usage/ForecastCard';
|
||||||
COLOR_PROMPT,
|
import { ModelQuotaBar } from '@/components/usage/ModelQuotaBar';
|
||||||
COLOR_COMPLETION,
|
import { CostReportCard, ModelCostEntry } from '@/components/usage/CostReportCard';
|
||||||
PIE_COLORS,
|
import { ModelPricingDialog } from '@/components/usage/ModelPricingDialog';
|
||||||
LineTooltip,
|
import { calculateCost } from '@/constants/pricing';
|
||||||
} from '@/components/usage/NivoTooltip';
|
import { COLOR_PROMPT, COLOR_COMPLETION, LineTooltip } from '@/components/usage/NivoTooltip';
|
||||||
import { nivoTheme } from '@/lib/nivo-theme';
|
import { nivoTheme } from '@/lib/nivo-theme';
|
||||||
|
import { MonitorTab } from '@/components/usage/MonitorTab';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Layers,
|
Layers,
|
||||||
@@ -29,6 +33,10 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Radio,
|
Radio,
|
||||||
Zap,
|
Zap,
|
||||||
|
DollarSign,
|
||||||
|
Info,
|
||||||
|
AlertTriangle,
|
||||||
|
Settings,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
@@ -129,10 +137,11 @@ function UsagePage() {
|
|||||||
const [range, setRange] = useState<TimeRange>('7d');
|
const [range, setRange] = useState<TimeRange>('7d');
|
||||||
const ALL_ACCOUNTS = '__all__';
|
const ALL_ACCOUNTS = '__all__';
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||||
|
const [isPricingDialogOpen, setIsPricingDialogOpen] = useState(false);
|
||||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||||
|
const { config, saveConfig } = useAppConfig();
|
||||||
|
|
||||||
const accountFilter =
|
const accountFilter = selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
||||||
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
|
||||||
|
|
||||||
/* ───── Current period data ───── */
|
/* ───── Current period data ───── */
|
||||||
const {
|
const {
|
||||||
@@ -203,7 +212,6 @@ function UsagePage() {
|
|||||||
end,
|
end,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
enabled: range === '24h',
|
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
@@ -211,10 +219,7 @@ function UsagePage() {
|
|||||||
|
|
||||||
/* ───── Derived data ───── */
|
/* ───── Derived data ───── */
|
||||||
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
||||||
const prevTotals = useMemo(
|
const prevTotals = useMemo(() => sumBuckets(prevDailyData || []), [prevDailyData]);
|
||||||
() => sumBuckets(prevDailyData || []),
|
|
||||||
[prevDailyData],
|
|
||||||
);
|
|
||||||
|
|
||||||
const promptRatio = totals.totalTokens
|
const promptRatio = totals.totalTokens
|
||||||
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
||||||
@@ -222,33 +227,22 @@ function UsagePage() {
|
|||||||
const completionRatio = totals.totalTokens
|
const completionRatio = totals.totalTokens
|
||||||
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const avgReqSize = totals.requests
|
const avgReqSize = totals.requests ? Math.round(totals.totalTokens / totals.requests) : 0;
|
||||||
? Math.round(totals.totalTokens / totals.requests)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const sparklineTotal = useMemo(
|
const sparklineTotal = useMemo(() => (dailyData || []).map((d) => d.totalTokens), [dailyData]);
|
||||||
() => (dailyData || []).map((d) => d.totalTokens),
|
const sparklinePrompt = useMemo(() => (dailyData || []).map((d) => d.promptTokens), [dailyData]);
|
||||||
[dailyData],
|
|
||||||
);
|
|
||||||
const sparklinePrompt = useMemo(
|
|
||||||
() => (dailyData || []).map((d) => d.promptTokens),
|
|
||||||
[dailyData],
|
|
||||||
);
|
|
||||||
const sparklineCompletion = useMemo(
|
const sparklineCompletion = useMemo(
|
||||||
() => (dailyData || []).map((d) => d.completionTokens),
|
() => (dailyData || []).map((d) => d.completionTokens),
|
||||||
[dailyData],
|
[dailyData],
|
||||||
);
|
);
|
||||||
const sparklineRequests = useMemo(
|
const sparklineRequests = useMemo(() => (dailyData || []).map((d) => d.requests), [dailyData]);
|
||||||
() => (dailyData || []).map((d) => d.requests),
|
|
||||||
[dailyData],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Area chart — Nivo line format
|
// Area chart — Nivo line format
|
||||||
const areaChartData = useMemo(() => {
|
const areaChartData = useMemo(() => {
|
||||||
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: 'Prompt',
|
id: 'Input Tokens',
|
||||||
color: COLOR_PROMPT,
|
color: COLOR_PROMPT,
|
||||||
data: source.map((d) => ({
|
data: source.map((d) => ({
|
||||||
x: formatBucketLabel(d.bucket, range),
|
x: formatBucketLabel(d.bucket, range),
|
||||||
@@ -256,7 +250,7 @@ function UsagePage() {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'Completion',
|
id: 'Output Tokens',
|
||||||
color: COLOR_COMPLETION,
|
color: COLOR_COMPLETION,
|
||||||
data: source.map((d) => ({
|
data: source.map((d) => ({
|
||||||
x: formatBucketLabel(d.bucket, range),
|
x: formatBucketLabel(d.bucket, range),
|
||||||
@@ -266,30 +260,73 @@ function UsagePage() {
|
|||||||
];
|
];
|
||||||
}, [dailyData, hourlyData, range]);
|
}, [dailyData, hourlyData, range]);
|
||||||
|
|
||||||
// Model ranking bar data
|
// Model quota bar data and cost estimation
|
||||||
const modelBarData = useMemo(() => {
|
const { topModelsData, costReport, totalCost, hasQuotaWarning } = useMemo(() => {
|
||||||
if (!modelData?.length) {
|
let cost = 0;
|
||||||
return [];
|
let warning = false;
|
||||||
}
|
const report: ModelCostEntry[] = [];
|
||||||
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) {
|
if (!modelData?.length) {
|
||||||
return [];
|
return { topModelsData: [], costReport: [], totalCost: 0, hasQuotaWarning: false };
|
||||||
}
|
}
|
||||||
return [...modelData]
|
|
||||||
|
const accountQuotas =
|
||||||
|
selectedAccountId !== ALL_ACCOUNTS
|
||||||
|
? accounts?.find((a) => a.id === selectedAccountId)?.quota?.models
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const processed = [...modelData]
|
||||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||||
.slice(0, 5);
|
.map((m) => {
|
||||||
}, [modelData]);
|
const inputCost = calculateCost(m.model, m.promptTokens, 0, config?.custom_model_pricing);
|
||||||
|
const outputCost = calculateCost(
|
||||||
|
m.model,
|
||||||
|
0,
|
||||||
|
m.completionTokens,
|
||||||
|
config?.custom_model_pricing,
|
||||||
|
);
|
||||||
|
const mTotalCost = calculateCost(
|
||||||
|
m.model,
|
||||||
|
m.promptTokens,
|
||||||
|
m.completionTokens,
|
||||||
|
config?.custom_model_pricing,
|
||||||
|
);
|
||||||
|
|
||||||
|
cost += mTotalCost;
|
||||||
|
|
||||||
|
report.push({
|
||||||
|
model: m.model,
|
||||||
|
inputTokens: m.promptTokens,
|
||||||
|
outputTokens: m.completionTokens,
|
||||||
|
inputCost,
|
||||||
|
outputCost,
|
||||||
|
totalCost: mTotalCost,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use percentage if available directly from the API response
|
||||||
|
const percentage = accountQuotas?.[m.model]?.percentage;
|
||||||
|
if (percentage && percentage >= 80) {
|
||||||
|
warning = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since limit tokens isn't stored in quotas exactly, we just use undefined
|
||||||
|
// to not display limit stats in the bar but just usage
|
||||||
|
const limitTokens = undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
model: m.model,
|
||||||
|
tokens: m.totalTokens,
|
||||||
|
limit: limitTokens,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
topModelsData: processed.slice(0, 8),
|
||||||
|
costReport: report.sort((a, b) => b.totalCost - a.totalCost),
|
||||||
|
totalCost: cost,
|
||||||
|
hasQuotaWarning: warning,
|
||||||
|
};
|
||||||
|
}, [modelData, accounts, selectedAccountId]);
|
||||||
|
|
||||||
const isAreaEmpty =
|
const isAreaEmpty =
|
||||||
range === '24h'
|
range === '24h'
|
||||||
@@ -299,6 +336,14 @@ function UsagePage() {
|
|||||||
const areaError = range === '24h' ? hourlyError : dailyError;
|
const areaError = range === '24h' ? hourlyError : dailyError;
|
||||||
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
||||||
|
|
||||||
|
const handleSavePricing = async (
|
||||||
|
pricing: Record<string, { input: number; output: number; source: string }>,
|
||||||
|
) => {
|
||||||
|
if (config) {
|
||||||
|
await saveConfig({ ...config, custom_model_pricing: pricing });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
/* ───── Render ───── */
|
/* ───── Render ───── */
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-auto">
|
<div className="flex h-full flex-col overflow-auto">
|
||||||
@@ -311,9 +356,7 @@ function UsagePage() {
|
|||||||
</h1>
|
</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">
|
<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" />
|
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
|
||||||
<span className="text-[10px] font-medium text-emerald-400">
|
<span className="text-[10px] font-medium text-emerald-400">Live</span>
|
||||||
Live
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
||||||
@@ -347,10 +390,7 @@ function UsagePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Tabs
|
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
||||||
value={range}
|
|
||||||
onValueChange={(v) => setRange(v as TimeRange)}
|
|
||||||
>
|
|
||||||
<TabsList className="h-8">
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="24h" className="px-3 text-xs">
|
<TabsTrigger value="24h" className="px-3 text-xs">
|
||||||
24h
|
24h
|
||||||
@@ -366,9 +406,30 @@ function UsagePage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-4 px-6 py-5">
|
<Tabs defaultValue="analytics" className="flex-1 flex flex-col">
|
||||||
|
<div className="px-6 pt-5">
|
||||||
|
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||||
|
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||||
|
<TabsTrigger value="monitor">Monitor</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
<TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5">
|
||||||
|
{hasQuotaWarning && (
|
||||||
|
<Alert
|
||||||
|
variant="destructive"
|
||||||
|
className="border-amber-500/50 bg-amber-500/10 text-amber-500"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="h-4 w-4" />
|
||||||
|
<AlertTitle>Quota Warning</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
One or more models have exceeded 80% of their allocated usage limit.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* ─── Stat cards ─── */}
|
{/* ─── Stat cards ─── */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<TooltipProvider delayDuration={300}>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Tokens"
|
label="Total Tokens"
|
||||||
value={formatNumber(totals.totalTokens)}
|
value={formatNumber(totals.totalTokens)}
|
||||||
@@ -376,14 +437,25 @@ function UsagePage() {
|
|||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="amber"
|
accent="amber"
|
||||||
trend={
|
trend={
|
||||||
prevDailyData
|
prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
|
||||||
? calcTrend(totals.totalTokens, prevTotals.totalTokens)
|
|
||||||
: undefined
|
|
||||||
}
|
}
|
||||||
sparkline={sparklineTotal}
|
sparkline={sparklineTotal}
|
||||||
/>
|
/>
|
||||||
|
<div className="group relative">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Prompt Tokens"
|
label={
|
||||||
|
<div className="flex cursor-help items-center gap-1.5">
|
||||||
|
Input Tokens
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Tokens sent to the model (prompt, context, files).</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
value={formatNumber(totals.promptTokens)}
|
value={formatNumber(totals.promptTokens)}
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
@@ -395,46 +467,86 @@ function UsagePage() {
|
|||||||
}
|
}
|
||||||
sparkline={sparklinePrompt}
|
sparkline={sparklinePrompt}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="group relative">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Completion Tokens"
|
label={
|
||||||
|
<div className="flex cursor-help items-center gap-1.5">
|
||||||
|
Output Tokens
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Tokens generated by the model in response.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
value={formatNumber(totals.completionTokens)}
|
value={formatNumber(totals.completionTokens)}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="green"
|
accent="green"
|
||||||
trend={
|
trend={
|
||||||
prevDailyData
|
prevDailyData
|
||||||
? calcTrend(
|
? calcTrend(totals.completionTokens, prevTotals.completionTokens)
|
||||||
totals.completionTokens,
|
|
||||||
prevTotals.completionTokens,
|
|
||||||
)
|
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
sparkline={sparklineCompletion}
|
sparkline={sparklineCompletion}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Total Requests"
|
label="Total Requests"
|
||||||
value={totals.requests.toLocaleString()}
|
value={totals.requests.toLocaleString()}
|
||||||
icon={Layers}
|
icon={Layers}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="slate"
|
accent="slate"
|
||||||
trend={
|
trend={prevDailyData ? calcTrend(totals.requests, prevTotals.requests) : undefined}
|
||||||
prevDailyData
|
|
||||||
? calcTrend(totals.requests, prevTotals.requests)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
sparkline={sparklineRequests}
|
sparkline={sparklineRequests}
|
||||||
/>
|
/>
|
||||||
|
<div className="group relative">
|
||||||
|
<StatCard
|
||||||
|
label={
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<div className="flex cursor-help items-center gap-1.5">
|
||||||
|
Estimated Cost
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>Approximate cost based on configured pricing.</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="ml-2 h-5 w-5 hover:bg-white/10"
|
||||||
|
onClick={() => setIsPricingDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<Settings className="text-muted-foreground h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
value={`$${totalCost.toFixed(2)}`}
|
||||||
|
icon={DollarSign}
|
||||||
|
isLoading={modelLoading}
|
||||||
|
accent="purple"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
||||||
{!dailyLoading && totals.totalTokens > 0 && (
|
{!dailyLoading && totals.totalTokens > 0 && (
|
||||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||||
<div className="col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-card p-4 lg:col-span-2">
|
<div className="bg-card col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] p-4 lg:col-span-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||||
Prompt / Completion
|
Input / Output
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
<span className="text-muted-foreground font-mono text-xs">
|
||||||
{formatNumber(totals.totalTokens)} total
|
{formatNumber(totals.totalTokens)} total
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -461,76 +573,30 @@ function UsagePage() {
|
|||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: COLOR_PROMPT }}
|
style={{ backgroundColor: COLOR_PROMPT }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">Prompt</span>
|
<span className="text-muted-foreground">Input</span>
|
||||||
<span className="font-mono font-semibold text-foreground">
|
<span className="text-foreground font-mono font-semibold">{promptRatio}%</span>
|
||||||
{promptRatio}%
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
style={{ backgroundColor: COLOR_COMPLETION }}
|
style={{ backgroundColor: COLOR_COMPLETION }}
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground">Completion</span>
|
<span className="text-muted-foreground">Output</span>
|
||||||
<span className="font-mono font-semibold text-foreground">
|
<span className="text-foreground font-mono font-semibold">
|
||||||
{completionRatio}%
|
{completionRatio}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-muted-foreground">
|
<span className="text-muted-foreground">
|
||||||
Avg/req:{' '}
|
Avg/req:{' '}
|
||||||
<span className="font-mono font-medium text-foreground">
|
<span className="text-foreground font-mono font-medium">
|
||||||
{formatNumber(avgReqSize)}
|
{formatNumber(avgReqSize)}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top models */}
|
<ForecastCard hourlyData={hourlyData} dailyData={dailyData} range={range} />
|
||||||
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -542,9 +608,7 @@ function UsagePage() {
|
|||||||
isLoading={areaLoading}
|
isLoading={areaLoading}
|
||||||
error={areaError}
|
error={areaError}
|
||||||
isEmpty={isAreaEmpty}
|
isEmpty={isAreaEmpty}
|
||||||
onRetry={() =>
|
onRetry={() => (range === '24h' ? refetchHourly() : refetchDaily())}
|
||||||
range === '24h' ? refetchHourly() : refetchDaily()
|
|
||||||
}
|
|
||||||
emptyTitle="No usage data"
|
emptyTitle="No usage data"
|
||||||
emptyDescription="Data will appear here once proxy requests are recorded."
|
emptyDescription="Data will appear here once proxy requests are recorded."
|
||||||
errorMessage="Failed to load usage data"
|
errorMessage="Failed to load usage data"
|
||||||
@@ -579,8 +643,7 @@ function UsagePage() {
|
|||||||
tickSize: 0,
|
tickSize: 0,
|
||||||
tickPadding: 8,
|
tickPadding: 8,
|
||||||
tickRotation: 0,
|
tickRotation: 0,
|
||||||
format: (v: number) =>
|
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||||
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
|
|
||||||
}}
|
}}
|
||||||
tooltip={LineTooltip}
|
tooltip={LineTooltip}
|
||||||
legends={[
|
legends={[
|
||||||
@@ -617,47 +680,46 @@ function UsagePage() {
|
|||||||
errorMessage="Failed to load model data"
|
errorMessage="Failed to load model data"
|
||||||
retryLabel="Retry"
|
retryLabel="Retry"
|
||||||
>
|
>
|
||||||
<div className="h-[320px]">
|
<div className="custom-scrollbar h-[320px] overflow-y-auto pr-2">
|
||||||
<ResponsiveBar
|
{topModelsData.map((m) => (
|
||||||
data={modelBarData}
|
<ModelQuotaBar
|
||||||
keys={['tokens']}
|
key={m.model}
|
||||||
indexBy="model"
|
modelName={m.model}
|
||||||
layout="horizontal"
|
usedTokens={m.tokens}
|
||||||
theme={nivoTheme}
|
limitTokens={m.limit}
|
||||||
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>
|
</div>
|
||||||
</ChartCard>
|
</ChartCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* ─── Cost Report ─── */}
|
||||||
|
{!dailyLoading && modelData && modelData.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<CostReportCard
|
||||||
|
models={costReport}
|
||||||
|
totalInputCost={costReport.reduce((acc, r) => acc + r.inputCost, 0)}
|
||||||
|
totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)}
|
||||||
|
totalCost={totalCost}
|
||||||
|
isLoading={modelLoading}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* ─── Modals ─── */}
|
||||||
|
<ModelPricingDialog
|
||||||
|
open={isPricingDialogOpen}
|
||||||
|
onOpenChange={setIsPricingDialogOpen}
|
||||||
|
customPricing={config?.custom_model_pricing || {}}
|
||||||
|
onSavePricing={handleSavePricing}
|
||||||
|
availableModels={modelData?.map((m) => m.model) || []}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="monitor" className="flex-1 px-6 py-5">
|
||||||
|
<MonitorTab />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-1
@@ -9,11 +9,16 @@ import { ProxyConfig } from '../types/config';
|
|||||||
import { setServerConfig } from './server-config';
|
import { setServerConfig } from './server-config';
|
||||||
import { registerApiLogging } from './logging/api-logging.plugin';
|
import { registerApiLogging } from './logging/api-logging.plugin';
|
||||||
|
|
||||||
|
import { IS_DEV_ENVIRONMENT } from '../utils/paths';
|
||||||
|
|
||||||
let app: NestFastifyApplication | null = null;
|
let app: NestFastifyApplication | null = null;
|
||||||
let currentPort: number = 0;
|
let currentPort: number = 0;
|
||||||
|
|
||||||
|
/** Default port: 8046 in development, 8045 in production */
|
||||||
|
const DEFAULT_PORT = IS_DEV_ENVIRONMENT ? 8046 : 8045;
|
||||||
|
|
||||||
export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean> {
|
export async function bootstrapNestServer(config: ProxyConfig): Promise<boolean> {
|
||||||
const port = config.port || 8045;
|
const port = config.port || DEFAULT_PORT;
|
||||||
if (app) {
|
if (app) {
|
||||||
logger.info('NestJS server already running.');
|
logger.info('NestJS server already running.');
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -3,12 +3,18 @@ import { networkInterfaces } from 'os';
|
|||||||
import { getServerConfig } from '../../server-config';
|
import { getServerConfig } from '../../server-config';
|
||||||
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
|
import { registerProxyAdvancedService } from '../../../ipc/proxy-advanced/service-registry';
|
||||||
|
|
||||||
export type SupportedIde = 'cursor' | 'vscode' | 'claude-code' | 'jetbrains' | 'opencide';
|
export type SupportedIde =
|
||||||
|
| 'cursor'
|
||||||
|
| 'vscode'
|
||||||
|
| 'claude-code'
|
||||||
|
| 'jetbrains'
|
||||||
|
| 'opencide'
|
||||||
|
| 'opencode';
|
||||||
|
|
||||||
export interface IdeConfigResult {
|
export interface IdeConfigResult {
|
||||||
ide: SupportedIde;
|
ide: SupportedIde;
|
||||||
format: 'json' | 'shell' | 'env' | 'instructions';
|
format: 'json' | 'shell' | 'env' | 'instructions';
|
||||||
content: Record<string, string> | string;
|
content: Record<string, any> | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -46,6 +52,7 @@ export class ProxyIdeConfigService {
|
|||||||
port: number,
|
port: number,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
localIp: string,
|
localIp: string,
|
||||||
|
defaultModel: string = 'gemini-3.1-pro-high',
|
||||||
): IdeConfigResult {
|
): IdeConfigResult {
|
||||||
const baseUrl = `http://${localIp}:${port}/v1`;
|
const baseUrl = `http://${localIp}:${port}/v1`;
|
||||||
|
|
||||||
@@ -99,6 +106,20 @@ export class ProxyIdeConfigService {
|
|||||||
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
|
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
case 'opencode': {
|
||||||
|
return {
|
||||||
|
ide,
|
||||||
|
format: 'json',
|
||||||
|
content: {
|
||||||
|
apiType: 'openai',
|
||||||
|
apiKey: apiKey,
|
||||||
|
baseURL: baseUrl,
|
||||||
|
models: {
|
||||||
|
chat: [defaultModel],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
default: {
|
default: {
|
||||||
// Exhaustive fallback to satisfy TypeScript
|
// Exhaustive fallback to satisfy TypeScript
|
||||||
const exhaustiveCheck: never = ide;
|
const exhaustiveCheck: never = ide;
|
||||||
@@ -112,7 +133,7 @@ export class ProxyIdeConfigService {
|
|||||||
* Convenience helper that reads the current server config and local IP,
|
* Convenience helper that reads the current server config and local IP,
|
||||||
* then delegates to generateIdeConfig.
|
* then delegates to generateIdeConfig.
|
||||||
*/
|
*/
|
||||||
generateConfigForCurrentServer(ide: SupportedIde): IdeConfigResult | null {
|
generateConfigForCurrentServer(ide: SupportedIde, defaultModel?: string): IdeConfigResult | null {
|
||||||
const config = getServerConfig();
|
const config = getServerConfig();
|
||||||
if (!config) {
|
if (!config) {
|
||||||
this.logger.warn('Server config not available; cannot generate IDE config');
|
this.logger.warn('Server config not available; cannot generate IDE config');
|
||||||
@@ -121,6 +142,6 @@ export class ProxyIdeConfigService {
|
|||||||
const port = config.port;
|
const port = config.port;
|
||||||
const apiKey = config.api_key;
|
const apiKey = config.api_key;
|
||||||
const localIp = this.getLocalIp();
|
const localIp = this.getLocalIp();
|
||||||
return this.generateIdeConfig(ide, port, apiKey, localIp);
|
return this.generateIdeConfig(ide, port, apiKey, localIp, defaultModel);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,9 +25,7 @@ export class ProxyReplayService {
|
|||||||
private readonly buffer: ReplayEntry[] = [];
|
private readonly buffer: ReplayEntry[] = [];
|
||||||
private readonly maxSize = 20;
|
private readonly maxSize = 20;
|
||||||
|
|
||||||
constructor(
|
constructor(@Inject(ProxyService) private readonly proxyService: ProxyService) {
|
||||||
@Inject(ProxyService) private readonly proxyService: ProxyService,
|
|
||||||
) {
|
|
||||||
registerProxyAdvancedService('proxyReplay', this);
|
registerProxyAdvancedService('proxyReplay', this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loaded 100 of 362 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user