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 | ||
|
|
a631887cd1 | ||
|
|
4ce8d266ab | ||
|
|
68474b56dc | ||
|
|
48faa0d4b1 | ||
|
|
9bf7c46ff6 | ||
|
|
195af14707 | ||
|
|
a9a492274c | ||
|
|
3cf82a7999 |
No files matched your search
@@ -18,8 +18,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -18,8 +18,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
name: Publish Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
release:
|
||||
types: [published]
|
||||
workflow_run:
|
||||
workflows: ['Release']
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
@@ -20,7 +18,46 @@ on:
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
check-release:
|
||||
name: Check for new release
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
tag: ${{ steps.get_tag.outputs.tag }}
|
||||
has_release: ${{ steps.get_tag.outputs.has_release }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get latest release tag
|
||||
id: get_tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
version="$(node -e "process.stdout.write(require('./package.json').version)")"
|
||||
tag="v$version"
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "has_release=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName // empty')
|
||||
if [ -n "$tag" ]; then
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "has_release=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Found release: $tag"
|
||||
else
|
||||
echo "has_release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "No release found, skipping build."
|
||||
fi
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: check-release
|
||||
if: needs.check-release.outputs.has_release == 'true'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -55,8 +92,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install Node and NPM
|
||||
uses: actions/setup-node@v4
|
||||
@@ -136,16 +171,12 @@ jobs:
|
||||
echo "Dry run enabled: build artifacts generated and uploaded; release publish step will be skipped."
|
||||
|
||||
publish:
|
||||
name: Publish Release (Manual Approval)
|
||||
needs: build
|
||||
name: Publish Release
|
||||
needs: [check-release, build]
|
||||
runs-on: ubuntu-latest
|
||||
if: needs.build.result == 'success' && (github.event_name != 'workflow_dispatch' || github.event.inputs.dry_run != 'true')
|
||||
permissions:
|
||||
contents: write
|
||||
# Configure the `release` environment with required reviewers in GitHub settings
|
||||
# to enforce a manual approval gate before assets are uploaded.
|
||||
environment:
|
||||
name: release
|
||||
|
||||
steps:
|
||||
- name: Checkout git repo
|
||||
@@ -158,54 +189,17 @@ jobs:
|
||||
merge-multiple: true
|
||||
path: release-assets
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Install Node and NPM
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Resolve release tag
|
||||
id: release_tag
|
||||
env:
|
||||
RELEASE_EVENT_TAG: ${{ github.event_name == 'release' && github.event.release.tag_name || '' }}
|
||||
run: |
|
||||
if [ -n "$RELEASE_EVENT_TAG" ]; then
|
||||
tag="$RELEASE_EVENT_TAG"
|
||||
else
|
||||
version="$(node -e "const v=require('./package.json').version; if (!v) { throw new Error('package.json version is empty'); } process.stdout.write(v);")"
|
||||
tag="${{ needs.check-release.outputs.tag }}"
|
||||
if [ -z "$tag" ]; then
|
||||
version="$(node -e "process.stdout.write(require('./package.json').version)")"
|
||||
tag="v$version"
|
||||
fi
|
||||
|
||||
if [ -z "$tag" ]; then
|
||||
echo "::error::Resolved release tag is empty"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Using release tag: $tag"
|
||||
|
||||
- name: Ensure release is safe to update
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.release_tag.outputs.tag }}
|
||||
ALLOW_UPDATE_PUBLISHED_RELEASE: ${{ github.event_name == 'release' && 'true' || (github.event_name == 'workflow_dispatch' && github.event.inputs.allow_update_published_release) || 'false' }}
|
||||
run: |
|
||||
if gh release view "$RELEASE_TAG" >/dev/null 2>&1; then
|
||||
is_draft="$(gh release view "$RELEASE_TAG" --json isDraft --jq '.isDraft')"
|
||||
if [ "$is_draft" != "true" ] && [ "$ALLOW_UPDATE_PUBLISHED_RELEASE" != "true" ]; then
|
||||
echo "::error::Release $RELEASE_TAG is already published. Refusing to upload assets."
|
||||
echo "::error::Re-run with workflow_dispatch input allow_update_published_release=true if you explicitly want to update it."
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
gh release create "$RELEASE_TAG" --title "$RELEASE_TAG" --draft --notes "Automated release draft."
|
||||
fi
|
||||
|
||||
- name: Upload assets to release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -23,8 +23,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
@@ -20,8 +20,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
@@ -44,8 +42,6 @@ jobs:
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
+29
-1
@@ -125,4 +125,32 @@ cli/aliases.json
|
||||
accounts_backup.json
|
||||
|
||||
# Bun lock file
|
||||
bun.lock
|
||||
bun.lock
|
||||
|
||||
# Build tools (installed via CI)
|
||||
tools/
|
||||
*.zip
|
||||
|
||||
# AI agent session data
|
||||
thoughts/
|
||||
.impeccable.md
|
||||
CLAUDE.md
|
||||
|
||||
# One-shot scripts (already applied)
|
||||
fix-ts.js
|
||||
patch-proxy.js
|
||||
patch-settings.js
|
||||
rename-deep.js
|
||||
test-process.ts
|
||||
|
||||
# Legacy CLI launchers
|
||||
agm.bat
|
||||
run_cli.bat
|
||||
|
||||
# Nix (not used)
|
||||
flake.nix
|
||||
flake.lock
|
||||
|
||||
# Planning/spec data
|
||||
.planning/
|
||||
openspec/
|
||||
@@ -1,29 +0,0 @@
|
||||
## Design Context
|
||||
|
||||
### Users
|
||||
|
||||
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
|
||||
|
||||
### Brand Personality
|
||||
|
||||
- **Professional**: Clean, precise, no clutter
|
||||
- **Technical**: Data-dense but readable, developer-friendly
|
||||
- **Efficient**: Information at a glance, minimal clicks
|
||||
- 3 words: "precise", "technical", "focused"
|
||||
|
||||
### Aesthetic Direction
|
||||
|
||||
- Dark mode primary (this is a developer tool used for long sessions)
|
||||
- Deep navy/slate backgrounds, not pure black
|
||||
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
|
||||
- High information density without visual noise
|
||||
- Monospace for numbers/data, clean sans-serif for UI chrome
|
||||
- No gradients on text, no glassmorphism, no rounded-everything
|
||||
|
||||
### Design Principles
|
||||
|
||||
1. Data first — charts and numbers are the heroes
|
||||
2. Every pixel earns its place — no decorative elements
|
||||
3. Density over whitespace — developers want to see everything
|
||||
4. Motion only for state changes — no gratuitous animations
|
||||
5. Consistent with shadcn/ui + Tailwind system already in place
|
||||
@@ -4,17 +4,20 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current State
|
||||
|
||||
The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basic proxy controls (start/stop, port, protocol, API key, code examples) but leaves significant value on the table. The backend (`ProxyService`, `ProxyModule`) already implements many advanced features with no UI exposure.
|
||||
|
||||
## Proposed New Features
|
||||
|
||||
### 1. Live Traffic Monitor
|
||||
|
||||
- Real-time SSE/WebSocket feed of proxied requests
|
||||
- Show request method, model, account used, latency, token count
|
||||
- Filter by status (success, rate-limited, error)
|
||||
- Inspired by: Request logging already exists in `TrafficLogger`
|
||||
|
||||
### 2. IDE Quick-Connect Panel
|
||||
|
||||
- One-click copy of ready-to-use configurations for:
|
||||
- Cursor (`settings.json` snippet)
|
||||
- OpenCode (`.env` snippet)
|
||||
@@ -24,6 +27,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- QR code for mobile/quick access
|
||||
|
||||
### 3. API Playground
|
||||
|
||||
- Built-in Swagger/OpenAPI UI or custom test panel
|
||||
- Pre-loaded with all supported endpoints:
|
||||
- `/v1/chat/completions` (OpenAI-compatible)
|
||||
@@ -33,6 +37,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Show exact curl equivalent for every request
|
||||
|
||||
### 4. Health Dashboard
|
||||
|
||||
- Real-time metrics cards:
|
||||
- Requests/minute
|
||||
- Average latency (p50, p95, p99)
|
||||
@@ -43,6 +48,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Color-coded status indicators
|
||||
|
||||
### 5. Model Availability Status
|
||||
|
||||
- Live grid showing which models are currently available
|
||||
- Per-model indicators:
|
||||
- Green: available
|
||||
@@ -52,6 +58,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Collapse/expand by provider (Gemini, Anthropic)
|
||||
|
||||
### 6. Error Feed
|
||||
|
||||
- Scrollable log of recent errors
|
||||
- Columns: timestamp, endpoint, model, account, error code, message
|
||||
- Click to expand full request/response
|
||||
@@ -59,6 +66,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Export to JSON/CSV
|
||||
|
||||
### 7. Connection Visualizer
|
||||
|
||||
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
|
||||
- Animated flow dots representing live requests
|
||||
- Highlight currently active account
|
||||
@@ -66,6 +74,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Simple but effective for understanding routing
|
||||
|
||||
### 8. One-Click Diagnostics
|
||||
|
||||
- "Run Diagnostics" button that performs:
|
||||
- Check if proxy port is accessible
|
||||
- Test each configured account
|
||||
@@ -75,6 +84,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
- Output: green checkmarks or detailed error report with fix suggestions
|
||||
|
||||
### 9. Session Stats
|
||||
|
||||
- Summary of current session since proxy started:
|
||||
- Total requests handled
|
||||
- Total tokens proxied
|
||||
@@ -86,6 +96,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
|
||||
---
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
|
||||
- Health metrics likely already exist in `ProxyService` but need exposure
|
||||
- Traffic logging exists via `TrafficLogger` — just needs UI
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current Pain Points
|
||||
|
||||
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
|
||||
2. **Information overload**: All content is visible at once — no progressive disclosure
|
||||
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
|
||||
@@ -14,29 +15,35 @@
|
||||
## Proposed Improvements
|
||||
|
||||
### 1. Remove Gradient Backgrounds
|
||||
|
||||
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
|
||||
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
|
||||
- Align with the rest of the app which uses flat, technical styling
|
||||
|
||||
### 2. Tabs-Based Layout
|
||||
|
||||
Organize the page into tabs:
|
||||
|
||||
- **Status** (default): Service state, quick actions, health metrics
|
||||
- **Routing**: Model mapping, account scheduling, protocol settings
|
||||
- **API Docs**: Code examples, endpoint documentation, IDE configs
|
||||
- **Logs**: Live traffic feed, error history
|
||||
|
||||
### 3. Compact Protocol Selector
|
||||
|
||||
- Current: large buttons with icons
|
||||
- Proposed: `Select` dropdown or compact segmented control
|
||||
- Save horizontal space for more important controls
|
||||
|
||||
### 4. Collapsible Code Examples
|
||||
|
||||
- Wrap each code block in a `Collapsible` or `Accordion`
|
||||
- Show only the title + language icon by default
|
||||
- Expand on click
|
||||
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
|
||||
|
||||
### 5. Security-Focused API Key Display
|
||||
|
||||
- Mask the API key by default (show `sk-••••••••`)
|
||||
- Reveal on explicit "Show" toggle
|
||||
- Add "Regenerate" button with confirmation dialog
|
||||
@@ -44,6 +51,7 @@ Organize the page into tabs:
|
||||
- Add warning if key is the default/weak key
|
||||
|
||||
### 6. Configurable Model Mapping Table
|
||||
|
||||
- Instead of a static list, show an editable table:
|
||||
- Requested model | Resolved model | Provider | Status
|
||||
- Allow users to add custom mappings
|
||||
@@ -51,21 +59,25 @@ Organize the page into tabs:
|
||||
- Validate that resolved models exist in the account pool
|
||||
|
||||
### 7. Account Quota Context
|
||||
|
||||
- Show the currently selected "primary" account with its quota bar
|
||||
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
|
||||
- Link to the account pool page for detailed management
|
||||
|
||||
### 8. Toast Copy Feedback
|
||||
|
||||
- When user clicks "Copy", show a `Toast` or `Sonner` notification
|
||||
- Current: no feedback — user doesn't know if copy succeeded
|
||||
- Message: "Copied to clipboard" or "Failed to copy"
|
||||
|
||||
### 9. Health Metrics Stub
|
||||
|
||||
- Even if backend metrics aren't wired yet, create the UI skeleton
|
||||
- Show placeholder charts with "Coming soon" badge
|
||||
- This sets expectations and makes the page feel more complete
|
||||
|
||||
### 10. Empty States
|
||||
|
||||
- When proxy is stopped, show a clear empty state:
|
||||
- Icon: `ServerOff` from lucide
|
||||
- Message: "Proxy is not running"
|
||||
@@ -75,6 +87,7 @@ Organize the page into tabs:
|
||||
---
|
||||
|
||||
## Design Principles
|
||||
|
||||
- **Density over whitespace**: This is a developer tool — show more information per pixel
|
||||
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
|
||||
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Discovery
|
||||
|
||||
The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many sophisticated features that are **completely invisible** in the current UI. The proxy page only shows: port, protocol, API key, and basic start/stop.
|
||||
|
||||
## Hidden Backend Features
|
||||
|
||||
### 1. Advanced Routing & Scheduling Controls
|
||||
|
||||
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
|
||||
- `scheduler.maxWaitTime`: Max time to wait for an available account
|
||||
- `scheduler.retryAttempts`: Number of retries per request
|
||||
@@ -20,8 +23,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. These are hardcoded or set via config files only.
|
||||
|
||||
### 2. Live Traffic Inspector
|
||||
|
||||
**Backend location**: `traffic-logger.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `TrafficLogger` captures every request/response with metadata
|
||||
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
|
||||
- Log rotation and cleanup policies
|
||||
@@ -30,8 +35,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Logs are only written to files.
|
||||
|
||||
### 3. Circuit Breaker Dashboard
|
||||
|
||||
**Backend location**: `proxy-circuit-breaker.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
|
||||
- Failure threshold and recovery timeout
|
||||
- When open: requests fail fast without hitting the API
|
||||
@@ -40,8 +47,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Circuit breaker state is internal only.
|
||||
|
||||
### 4. Parity Testing Sandbox
|
||||
|
||||
**Backend location**: `proxy-parity.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Send identical requests to multiple models simultaneously
|
||||
- Compare responses for consistency
|
||||
- Detect model drift or API changes
|
||||
@@ -50,8 +59,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Only accessible via manual API calls or scripts.
|
||||
|
||||
### 5. Custom Model Mapping Table
|
||||
|
||||
**Backend location**: `proxy-model-mapper.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
|
||||
- User-defined mappings stored in database
|
||||
- Regex-based model matching
|
||||
@@ -60,8 +71,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: A static, read-only list is shown. No editing capability.
|
||||
|
||||
### 6. Upstream Proxy Configuration
|
||||
|
||||
**Backend location**: `proxy-upstream.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `upstreamProxy.url`: Route all traffic through another proxy
|
||||
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
|
||||
- `upstreamProxy.bypassList`: Domains to bypass
|
||||
@@ -69,8 +82,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Set via environment variables only.
|
||||
|
||||
### 7. IDE Integration Quick-Setup
|
||||
|
||||
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
|
||||
**What's implemented**:
|
||||
|
||||
- Generates ready-to-paste configs for:
|
||||
- Cursor (`settings.json`)
|
||||
- VS Code + Cline/Roo Code
|
||||
@@ -82,8 +97,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
|
||||
|
||||
### 8. Health & Performance Metrics
|
||||
|
||||
**Backend location**: `proxy-metrics.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Request latency histogram (p50, p95, p99)
|
||||
- Requests per minute counter
|
||||
- Error rate by status code
|
||||
@@ -94,8 +111,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
|
||||
|
||||
### 9. Request Replay & Debug Tool
|
||||
|
||||
**Backend location**: `proxy-replay.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- Store recent requests (last 100 by default)
|
||||
- Replay any request with one click
|
||||
- Modify headers/body before replay
|
||||
@@ -105,8 +124,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
**UI exposure**: None.
|
||||
|
||||
### 10. Global Timeout & Max Wait Controls
|
||||
|
||||
**Backend location**: `proxy-config.service.ts`
|
||||
**What's implemented**:
|
||||
|
||||
- `requestTimeout`: Global timeout for all upstream requests
|
||||
- `connectTimeout`: TCP connection timeout
|
||||
- `maxWaitTime`: Max time to wait for an available account
|
||||
@@ -117,7 +138,9 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
|
||||
---
|
||||
|
||||
## Recommendation
|
||||
|
||||
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
|
||||
|
||||
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
|
||||
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
|
||||
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Current State
|
||||
|
||||
`src/routes/proxy.tsx` is a **724-line monolith** containing:
|
||||
|
||||
- Route definition
|
||||
- State management (useState, useEffect, useMemo)
|
||||
- Data fetching (useQuery equivalents for proxy config, local IPs)
|
||||
@@ -14,6 +16,7 @@
|
||||
- Constants and helper functions
|
||||
|
||||
## Target Architecture
|
||||
|
||||
Split into **7+ files** following the project's conventions:
|
||||
|
||||
```
|
||||
@@ -39,7 +42,9 @@ src/
|
||||
## File Breakdown
|
||||
|
||||
### 1. `src/hooks/useProxyConfig.ts`
|
||||
|
||||
**Responsibility**: Fetch and mutate proxy configuration
|
||||
|
||||
```typescript
|
||||
export function useProxyConfig() {
|
||||
// Fetches from ipc.client.proxy.getConfig()
|
||||
@@ -49,7 +54,9 @@ export function useProxyConfig() {
|
||||
```
|
||||
|
||||
### 2. `src/hooks/useLocalIps.ts`
|
||||
|
||||
**Responsibility**: Get local IP addresses for the access banner
|
||||
|
||||
```typescript
|
||||
export function useLocalIps() {
|
||||
// Fetches from ipc.client.system.getLocalIps()
|
||||
@@ -58,7 +65,9 @@ export function useLocalIps() {
|
||||
```
|
||||
|
||||
### 3. `src/hooks/useProxyExamples.ts`
|
||||
|
||||
**Responsibility**: Generate code examples based on current config
|
||||
|
||||
```typescript
|
||||
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
// Returns memoized examples for curl, Python, Node.js
|
||||
@@ -67,7 +76,9 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
```
|
||||
|
||||
### 4. `src/components/proxy/ProxyServiceCard.tsx`
|
||||
|
||||
**Responsibility**: The main service control card
|
||||
|
||||
- Start/stop toggle
|
||||
- Port input
|
||||
- Protocol selector
|
||||
@@ -75,26 +86,34 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
|
||||
- Status indicator
|
||||
|
||||
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
|
||||
|
||||
**Responsibility**: Model mapping display/editor
|
||||
|
||||
- Read-only list (current state)
|
||||
- Future: editable table with add/remove
|
||||
- Show provider badges
|
||||
|
||||
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
|
||||
|
||||
**Responsibility**: Code examples section
|
||||
|
||||
- Collapsible/accordion layout
|
||||
- Tabs for different languages
|
||||
- Copy-to-clipboard buttons
|
||||
- Syntax highlighting (if available)
|
||||
|
||||
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
|
||||
|
||||
**Responsibility**: "Access your proxy locally" banner
|
||||
|
||||
- Show local IPs
|
||||
- QR code (future)
|
||||
- Copy URL buttons
|
||||
|
||||
### 8. `src/constants/proxy.ts`
|
||||
|
||||
**Responsibility**: Constants
|
||||
|
||||
```typescript
|
||||
export const DEFAULT_PROXY_PORT = 3000;
|
||||
export const PROTOCOLS = ['http', 'https'] as const;
|
||||
@@ -102,7 +121,9 @@ export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
|
||||
```
|
||||
|
||||
### 9. `src/utils/proxy.ts`
|
||||
|
||||
**Responsibility**: Pure helper functions
|
||||
|
||||
```typescript
|
||||
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
|
||||
export function maskApiKey(key: string): string;
|
||||
@@ -141,6 +162,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
||||
```
|
||||
|
||||
## Migration Steps
|
||||
|
||||
1. Create new files (hooks, components, constants, utils)
|
||||
2. Move code from `proxy.tsx` to appropriate files
|
||||
3. Update imports in `proxy.tsx`
|
||||
@@ -149,6 +171,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
|
||||
6. Verify no regression in functionality
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Testability**: Each hook and component can be unit tested in isolation
|
||||
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
|
||||
- **Maintainability**: 50-line route file vs. 724-line monolith
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
> Date: 2026-04-22
|
||||
|
||||
## Severity Legend
|
||||
|
||||
- 🔴 **Critical**: Bugs or crashes
|
||||
- 🟡 **High**: Performance or reliability issues
|
||||
- 🟢 **Medium**: Maintainability or best practice violations
|
||||
@@ -11,21 +12,26 @@
|
||||
## Issues Found
|
||||
|
||||
### 1. 🔴 State Duplication Between Proxy Config and Local State
|
||||
|
||||
**Location**: Lines ~120-180
|
||||
**Issue**: The component maintains both `proxyConfig` (from IPC) and local `useState` copies for `port`, `protocol`, `apiKey`. These can drift out of sync if the backend config changes externally.
|
||||
**Fix**: Use a single source of truth. Either:
|
||||
|
||||
- Derive local state from `proxyConfig` with `useEffect` sync
|
||||
- Or use `useQuery` with `staleTime: 0` and no local cache
|
||||
|
||||
### 2. 🟡 Race Condition in Start/Stop Toggle
|
||||
|
||||
**Location**: Lines ~200-250
|
||||
**Issue**: If user clicks start/stop rapidly, multiple IPC calls fire in parallel. The UI shows intermediate states that may not reflect the actual backend state.
|
||||
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
|
||||
|
||||
### 3. 🟢 Clipboard Error Handling
|
||||
|
||||
**Location**: Lines ~300-320
|
||||
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
|
||||
**Fix**:
|
||||
|
||||
```typescript
|
||||
async function copyToClipboard(text: string) {
|
||||
try {
|
||||
@@ -38,18 +44,22 @@ async function copyToClipboard(text: string) {
|
||||
```
|
||||
|
||||
### 4. 🟢 Unstable Callbacks Causing Re-renders
|
||||
|
||||
**Location**: Throughout
|
||||
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
|
||||
**Fix**: Wrap handlers in `useCallback`:
|
||||
|
||||
```typescript
|
||||
const handleStart = useCallback(() => startProxy(), [startProxy]);
|
||||
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
|
||||
```
|
||||
|
||||
### 5. 🟡 Memory Leak in setTimeout
|
||||
|
||||
**Location**: Lines ~400-420
|
||||
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
|
||||
**Fix**: Use `useEffect` cleanup:
|
||||
|
||||
```typescript
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setCopied(false), 2000);
|
||||
@@ -58,24 +68,29 @@ useEffect(() => {
|
||||
```
|
||||
|
||||
### 6. 🟢 Missing Loading States for Mutations
|
||||
|
||||
**Location**: Lines ~200-250
|
||||
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
|
||||
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
|
||||
|
||||
### 7. 🟢 Hardcoded i18n Strings
|
||||
|
||||
**Location**: Throughout
|
||||
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
|
||||
|
||||
- "Proxy Service"
|
||||
- "Start Proxy"
|
||||
- "Stop Proxy"
|
||||
- "Copy"
|
||||
- "Copied!"
|
||||
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
||||
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
|
||||
|
||||
### 8. 🟢 Magic Numbers
|
||||
|
||||
**Location**: Lines ~450-500
|
||||
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
|
||||
**Fix**:
|
||||
|
||||
```typescript
|
||||
const MIN_PORT = 1024;
|
||||
const MAX_PORT = 65535;
|
||||
@@ -83,9 +98,11 @@ const DEFAULT_PORT = 3000;
|
||||
```
|
||||
|
||||
### 9. 🟢 Unsafe Type Cast
|
||||
|
||||
**Location**: Lines ~550-600
|
||||
**Issue**: `event.target.value` is cast to `Protocol` without validation.
|
||||
**Fix**:
|
||||
|
||||
```typescript
|
||||
const protocol = event.target.value;
|
||||
if (!PROTOCOLS.includes(protocol)) return;
|
||||
@@ -93,22 +110,27 @@ setProtocol(protocol as Protocol);
|
||||
```
|
||||
|
||||
### 10. 🟡 Dangerous Parsing of Local IPs
|
||||
|
||||
**Location**: Lines ~650-700
|
||||
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
|
||||
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
|
||||
|
||||
### 11. 🟢 Unnecessary Re-renders from Inline Objects
|
||||
|
||||
**Location**: Lines ~350-380
|
||||
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
|
||||
**Fix**: Extract static styles outside the component or memoize:
|
||||
|
||||
```typescript
|
||||
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
||||
```
|
||||
|
||||
### 12. 🟢 No Validation on Port Input
|
||||
|
||||
**Location**: Lines ~150-170
|
||||
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
|
||||
**Fix**: Add input validation:
|
||||
|
||||
```typescript
|
||||
<input
|
||||
type="number"
|
||||
@@ -125,6 +147,7 @@ const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
|
||||
---
|
||||
|
||||
## Testing Recommendations
|
||||
|
||||
- Add unit tests for `useProxyConfig` hook (mock IPC client)
|
||||
- Test race conditions: rapid start/stop clicks
|
||||
- Test clipboard failure path
|
||||
|
||||
@@ -6,41 +6,45 @@
|
||||
## File Map
|
||||
|
||||
### Frontend
|
||||
| File | Lines | Role |
|
||||
|------|-------|------|
|
||||
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
||||
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
||||
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
||||
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
||||
|
||||
| File | Lines | Role |
|
||||
| -------------------------------------------- | ----- | ---------------------------------------- |
|
||||
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
|
||||
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
|
||||
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
|
||||
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
|
||||
|
||||
### Backend (NestJS)
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
|
||||
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
|
||||
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
|
||||
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
|
||||
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
|
||||
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
|
||||
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
|
||||
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
|
||||
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
|
||||
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
|
||||
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
|
||||
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
||||
|
||||
| File | Role |
|
||||
| ----------------------------------------------------------- | ------------------------------------------- |
|
||||
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
|
||||
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
|
||||
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
|
||||
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
|
||||
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
|
||||
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
|
||||
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
|
||||
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
|
||||
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
|
||||
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
|
||||
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
|
||||
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
|
||||
|
||||
### IPC Layer
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/ipc/router.ts` | Main IPC router composition |
|
||||
|
||||
| File | Role |
|
||||
| ------------------------- | ---------------------------- |
|
||||
| `src/ipc/router.ts` | Main IPC router composition |
|
||||
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
|
||||
| `src/ipc/manager.ts` | IPC client initialization |
|
||||
| `src/ipc/manager.ts` | IPC client initialization |
|
||||
|
||||
### Types & Schema
|
||||
| File | Role |
|
||||
|------|------|
|
||||
|
||||
| File | Role |
|
||||
| --------------------- | ------------------------------------------------ |
|
||||
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
|
||||
| `src/types/proxy.ts` | Request/response types |
|
||||
| `src/types/proxy.ts` | Request/response types |
|
||||
|
||||
## Hidden ProxyConfig Fields
|
||||
|
||||
@@ -124,6 +128,7 @@ interface ProxyConfig {
|
||||
```
|
||||
|
||||
## Key Observations
|
||||
|
||||
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
|
||||
2. **IPC layer is thin** — most backend services are not exposed via IPC, only HTTP
|
||||
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
|
||||
@@ -131,6 +136,7 @@ interface ProxyConfig {
|
||||
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
|
||||
|
||||
## Recommended Architecture Changes
|
||||
|
||||
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
|
||||
2. Add IPC endpoints for:
|
||||
- `proxy.getTrafficLogs()`
|
||||
|
||||
@@ -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", "package-lock.json", "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)
|
||||
- **Node**: Recommended Node.js 20+
|
||||
- **Package manager**: `npm` (this project includes `package-lock.json`; use npm only)
|
||||
- **Package manager**: `pnpm` (this project includes `pnpm-lock.yaml`; use pnpm only)
|
||||
- **Build tools**: Electron Forge + Vite
|
||||
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
|
||||
|
||||
@@ -75,32 +75,32 @@ This document is a user-level guide for AI assistants working in this repository
|
||||
|
||||
## 📦 Common Scripts
|
||||
|
||||
Use `npm` for all commands:
|
||||
Use `pnpm` for all commands:
|
||||
|
||||
- **Development (Dev)**:
|
||||
- `npm start` - Start Electron dev environment (Electron Forge)
|
||||
- `npm run lint` - Run ESLint checks
|
||||
- `npm run format` - Run Prettier check
|
||||
- `npm run format:write` - Auto-format with Prettier
|
||||
- `npm run type-check` - Run TypeScript type check
|
||||
- `pnpm start` - Start Electron dev environment (Electron Forge)
|
||||
- `pnpm run lint` - Run ESLint checks
|
||||
- `pnpm run format` - Run Prettier check
|
||||
- `pnpm run format:write` - Auto-format with Prettier
|
||||
- `pnpm run type-check` - Run TypeScript type check
|
||||
|
||||
- **Build**:
|
||||
- `npm run package` - Package app (application bundle only)
|
||||
- `npm run make` - Build and generate distributable installers
|
||||
- `npm run publish` - Publish app
|
||||
- `pnpm run package` - Package app (application bundle only)
|
||||
- `pnpm run make` - Build and generate distributable installers
|
||||
- `pnpm run publish` - Publish app
|
||||
|
||||
- **Testing**:
|
||||
- `npm test` - Run Vitest tests
|
||||
- `npm run test:watch` - Run Vitest in watch mode
|
||||
- `npm run test:unit` - Same as above for unit-focused runs
|
||||
- `npm run test:e2e` - Run Playwright E2E tests
|
||||
- `npm run test:all` - Run all tests
|
||||
- `pnpm test` - Run Vitest tests
|
||||
- `pnpm run test:watch` - Run Vitest in watch mode
|
||||
- `pnpm run test:unit` - Same as above for unit-focused runs
|
||||
- `pnpm run test:e2e` - Run Playwright E2E tests
|
||||
- `pnpm run test:all` - Run all tests
|
||||
|
||||
### Running a Single Test
|
||||
|
||||
- Unit test: `npm run test:unit path/to/test.test.ts`
|
||||
- E2E test: `npm run test:e2e path/to/test.spec.ts`
|
||||
- Type check: `npm run type-check`
|
||||
- Unit test: `pnpm run test:unit path/to/test.test.ts`
|
||||
- E2E test: `pnpm run test:e2e path/to/test.spec.ts`
|
||||
- Type check: `pnpm run type-check`
|
||||
|
||||
## 🧪 Development Notes
|
||||
|
||||
@@ -171,7 +171,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
|
||||
};
|
||||
```
|
||||
|
||||
> Before commit, run `npm run lint` and `npm run format`.
|
||||
> Before commit, run `pnpm run lint` and `pnpm run format`.
|
||||
|
||||
## 📝 Terminal Output and References
|
||||
|
||||
@@ -330,7 +330,7 @@ Summary / output:
|
||||
|
||||
Packages and scripts:
|
||||
|
||||
- `npm install` (or `npm i`)
|
||||
- `pnpm install` (or `pnpm i`)
|
||||
|
||||
Shell:
|
||||
|
||||
|
||||
+65
-1
@@ -1,3 +1,67 @@
|
||||
<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)
|
||||
|
||||
### 🚀 Features
|
||||
|
||||
* **proxy:** add OpenAI Responses API compatibility for Open CoDesign ([68474b5](https://github.com/evandrodevbr/GeminiNexus/commit/68474b56dca9c9bcd7b06eaae04c521f475e8e2c))
|
||||
|
||||
## [0.13.1](https://github.com/evandrodevbr/GeminiNexus/compare/v0.13.0...v0.13.1) (2026-04-29)
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
|
||||
* lazy-initialize api-logger to prevent EACCES in CI environments ([9bf7c46](https://github.com/evandrodevbr/GeminiNexus/commit/9bf7c46ff6320f49cba80923c9e11eac35a7d4f0))
|
||||
|
||||
<a name="readme-top"></a>
|
||||
|
||||
# Changelog
|
||||
@@ -308,4 +372,4 @@
|
||||
- Secure credential storage (AES-256-GCM)
|
||||
- IDE synchronization
|
||||
- Dark mode support
|
||||
- System tray integration
|
||||
- System tray integration
|
||||
+22
-22
@@ -63,17 +63,17 @@ npm start
|
||||
|
||||
### Available Scripts
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm run format` | Check formatting with Prettier |
|
||||
| `npm run format:write` | Auto-format code with Prettier |
|
||||
| `npm run type-check` | Run TypeScript type check |
|
||||
| `npm test` | Run unit tests with Vitest |
|
||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||
| `npm run test:all` | Run all tests (unit + E2E) |
|
||||
| `npm run make` | Build production installers |
|
||||
| Command | Description |
|
||||
| ---------------------- | ------------------------------------------------------- |
|
||||
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
|
||||
| `npm run lint` | Run ESLint |
|
||||
| `npm run format` | Check formatting with Prettier |
|
||||
| `npm run format:write` | Auto-format code with Prettier |
|
||||
| `npm run type-check` | Run TypeScript type check |
|
||||
| `npm test` | Run unit tests with Vitest |
|
||||
| `npm run test:e2e` | Run E2E tests with Playwright |
|
||||
| `npm run test:all` | Run all tests (unit + E2E) |
|
||||
| `npm run make` | Build production installers |
|
||||
|
||||
### Project Structure
|
||||
|
||||
@@ -135,17 +135,17 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Commits
|
||||
|
||||
### Types
|
||||
|
||||
| Type | Description | Release |
|
||||
|------|-------------|---------|
|
||||
| `feat` | A new feature | **minor** |
|
||||
| `fix` | A bug fix | **patch** |
|
||||
| `perf` | Performance improvement | **patch** |
|
||||
| `refactor` | Code change (no bug fix or feature) | **patch** |
|
||||
| `docs` | Documentation only | no release |
|
||||
| `style` | Formatting, whitespace | no release |
|
||||
| `test` | Adding or fixing tests | no release |
|
||||
| `chore` | Build process, tooling | no release |
|
||||
| `ci` | CI/CD changes | no release |
|
||||
| Type | Description | Release |
|
||||
| ---------- | ----------------------------------- | ---------- |
|
||||
| `feat` | A new feature | **minor** |
|
||||
| `fix` | A bug fix | **patch** |
|
||||
| `perf` | Performance improvement | **patch** |
|
||||
| `refactor` | Code change (no bug fix or feature) | **patch** |
|
||||
| `docs` | Documentation only | no release |
|
||||
| `style` | Formatting, whitespace | no release |
|
||||
| `test` | Adding or fixing tests | no release |
|
||||
| `chore` | Build process, tooling | no release |
|
||||
| `ci` | CI/CD changes | no release |
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
|
||||
+10
-10
@@ -196,16 +196,16 @@ Model: gemini-3-flash
|
||||
|
||||
## 🛠️ Stack Tecnológico
|
||||
|
||||
| Categoría | Tecnologías |
|
||||
|-----------|------------|
|
||||
| **Core** | Electron, React 19, TypeScript |
|
||||
| **Build** | Vite, Electron Forge |
|
||||
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **Estado** | TanStack Query, TanStack Router |
|
||||
| **Backend** | NestJS (gateway interno), ORPC (RPC type-safe) |
|
||||
| **Base de Datos** | Better-SQLite3, Drizzle ORM |
|
||||
| **Gráficos** | Nivo (Line, Bar, Pie) |
|
||||
| **Testing** | Vitest, Playwright |
|
||||
| Categoría | Tecnologías |
|
||||
| ----------------- | ---------------------------------------------- |
|
||||
| **Core** | Electron, React 19, TypeScript |
|
||||
| **Build** | Vite, Electron Forge |
|
||||
| **Estilos** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **Estado** | TanStack Query, TanStack Router |
|
||||
| **Backend** | NestJS (gateway interno), ORPC (RPC type-safe) |
|
||||
| **Base de Datos** | Better-SQLite3, Drizzle ORM |
|
||||
| **Gráficos** | Nivo (Line, Bar, Pie) |
|
||||
| **Testing** | Vitest, Playwright |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+11
-12
@@ -159,7 +159,6 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
|
||||
|
||||
## 📸 Screenshots
|
||||
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/main.png" alt="Main" width="80%" />
|
||||
</p>
|
||||
@@ -223,17 +222,17 @@ Model: gemini-3-flash (ou qualquer modelo mapeado)
|
||||
|
||||
## 🛠️ Stack Tecnológica
|
||||
|
||||
| Categoria | Tecnologias |
|
||||
|-----------|------------|
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
||||
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
||||
| **Estado** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **Backend** | [NestJS](https://nestjs.com/) (gateway interno), [ORPC](https://orpc.unnoq.com/) (RPC type-safe) |
|
||||
| **Banco de Dados** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
|
||||
| **Gráficos** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
|
||||
| **i18n** | [react-i18next](https://react.i18next.com/) |
|
||||
| **Testes** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
| Categoria | Tecnologias |
|
||||
| ------------------ | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Core** | [Electron](https://www.electronjs.org/), [React 19](https://react.dev/), [TypeScript](https://www.typescriptlang.org/) |
|
||||
| **Build** | [Vite](https://vitejs.dev/), [Electron Forge](https://www.electronforge.io/) |
|
||||
| **Estilização** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
|
||||
| **Estado** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
|
||||
| **Backend** | [NestJS](https://nestjs.com/) (gateway interno), [ORPC](https://orpc.unnoq.com/) (RPC type-safe) |
|
||||
| **Banco de Dados** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
|
||||
| **Gráficos** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
|
||||
| **i18n** | [react-i18next](https://react.i18next.com/) |
|
||||
| **Testes** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+10
-10
@@ -194,16 +194,16 @@ Model: gemini-3-flash
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 分类 | 技术 |
|
||||
|------|------|
|
||||
| **核心** | Electron, React 19, TypeScript |
|
||||
| **构建** | Vite, Electron Forge |
|
||||
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **状态** | TanStack Query, TanStack Router |
|
||||
| **后端** | NestJS (内部网关), ORPC (类型安全 RPC) |
|
||||
| **数据库** | Better-SQLite3, Drizzle ORM |
|
||||
| **图表** | Nivo (Line, Bar, Pie) |
|
||||
| **测试** | Vitest, Playwright |
|
||||
| 分类 | 技术 |
|
||||
| ---------- | --------------------------------------- |
|
||||
| **核心** | Electron, React 19, TypeScript |
|
||||
| **构建** | Vite, Electron Forge |
|
||||
| **样式** | Tailwind CSS v4, Radix UI, Lucide Icons |
|
||||
| **状态** | TanStack Query, TanStack Router |
|
||||
| **后端** | NestJS (内部网关), ORPC (类型安全 RPC) |
|
||||
| **数据库** | Better-SQLite3, Drizzle ORM |
|
||||
| **图表** | Nivo (Line, Bar, Pie) |
|
||||
| **测试** | Vitest, Playwright |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
+48
-36
@@ -2,16 +2,16 @@
|
||||
|
||||
## 1. Nome e Descrição
|
||||
|
||||
| Campo | Valor |
|
||||
|-------|-------|
|
||||
| **Nome** | Gemini Nexus |
|
||||
| **Product Name** | Gemini Nexus |
|
||||
| **Package Name** | gemini-nexus |
|
||||
| **Versão** | 0.11.1 |
|
||||
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
|
||||
| Campo | Valor |
|
||||
| ------------------- | -------------------------------------------------------------------- |
|
||||
| **Nome** | Gemini Nexus |
|
||||
| **Product Name** | Gemini Nexus |
|
||||
| **Package Name** | gemini-nexus |
|
||||
| **Versão** | 0.11.1 |
|
||||
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
|
||||
| **Descrição PT-BR** | Gerenciador multi-contas profissional para Google Gemini & Claude AI |
|
||||
| **Autor** | evandrodevbr / Draculabo |
|
||||
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
|
||||
| **Autor** | evandrodevbr / Draculabo |
|
||||
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
|
||||
|
||||
---
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
|
||||
|
||||
### Funcionalidades Principais
|
||||
|
||||
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
|
||||
- **Auto-switching inteligente**: Troca automática de conta quando a cota acaba ou há rate limit
|
||||
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
|
||||
@@ -27,6 +28,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
|
||||
|
||||
### Público-Alvo
|
||||
|
||||
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
|
||||
- Power users que precisam alternar entre múltiplas contas de IA
|
||||
- Usuários que precisam de um proxy API local confiável
|
||||
@@ -46,28 +48,32 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
## 4. Estilo Visual Atual
|
||||
|
||||
### Tema
|
||||
|
||||
- Dual theme (claro/escuro) com detecção automática do sistema
|
||||
- Extremamente minimalista e quase monocromático
|
||||
|
||||
### Paleta de Cores (Tailwind OKLCH)
|
||||
|
||||
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
||||
|------|-----------|------------|---------|-------------|----------|
|
||||
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
|
||||
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
|
||||
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|
||||
| ---------- | ------------------------------ | --------------------- | ------- | --------------- | -------- |
|
||||
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
|
||||
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
|
||||
|
||||
### Cores Funcionais
|
||||
|
||||
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
|
||||
- **Amber/Yellow** — quota média, alertas
|
||||
- **Rose/Red** — quota baixa, erro, stopped
|
||||
- **Blue** — AI credits, informações premium
|
||||
|
||||
### Tipografia
|
||||
|
||||
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
|
||||
- **Geist Mono** (monospace) — dados técnicos, números de quota
|
||||
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
|
||||
|
||||
### Características de UI
|
||||
|
||||
- Bordas arredondadas (`0.5rem` / 8px)
|
||||
- Sombras sutis
|
||||
- Cards com hover states suaves
|
||||
@@ -77,45 +83,50 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
|
||||
## 5. Assets Visuais Existentes
|
||||
|
||||
| Arquivo | Descrição |
|
||||
|---------|-----------|
|
||||
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
||||
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
||||
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
||||
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
|
||||
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
|
||||
| `src/assets/tray.png` | Ícone da system tray |
|
||||
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
|
||||
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
|
||||
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
|
||||
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
|
||||
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
|
||||
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
|
||||
| Arquivo | Descrição |
|
||||
| ---------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
|
||||
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
|
||||
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
|
||||
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
|
||||
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
|
||||
| `src/assets/tray.png` | Ícone da system tray |
|
||||
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
|
||||
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
|
||||
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
|
||||
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
|
||||
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
|
||||
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
|
||||
|
||||
---
|
||||
|
||||
## 6. Conceitos Visuais Sugeridos para a Logo
|
||||
|
||||
### A. Nodo / Conexão (Nexus)
|
||||
|
||||
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
|
||||
- Círculo central com 2-3 linhas finas irradiando
|
||||
- Dois círculos interligados (Gemini + Claude)
|
||||
|
||||
### B. Infinito / Loop (Gemini + Proxy)
|
||||
|
||||
- Símbolo de infinito (∞) modificado como circuito/loop de dados
|
||||
- Referência à dualidade "Gemini" (gêmeos)
|
||||
- Curva contínua elegante com seta indicando fluxo
|
||||
|
||||
### C. Cubo / Hexágono (Nexus / Hub)
|
||||
|
||||
- Hexágono ou cubo isométrico minimalista
|
||||
- Linhas finas, perspectiva suave
|
||||
- Face "aberta" ou conectada
|
||||
|
||||
### D. Monograma "GN"
|
||||
|
||||
- Letras "G" e "N" abstraídas como nodo de rede
|
||||
- Estilo geométrico, similar a Tomorrow ou Geist
|
||||
|
||||
### E. Proxy / Pipe (Fluxo de Dados)
|
||||
|
||||
- Duas barras paralelas com seta passando entre elas
|
||||
- "Pipe" minimalista — conduz tráfego entre APIs
|
||||
|
||||
@@ -123,15 +134,15 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
|
||||
## 7. Diretrizes para a Nova Logo
|
||||
|
||||
| Elemento | Especificação |
|
||||
|----------|---------------|
|
||||
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
||||
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
||||
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
||||
| **Versões necessárias** | Dark mode e light mode |
|
||||
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
|
||||
| **Monocromática** | Deve funcionar em preto e branco puro |
|
||||
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
|
||||
| Elemento | Especificação |
|
||||
| ---------------------------- | ------------------------------------------------------------------------- |
|
||||
| **Estilo** | Minimalista, geométrica, precisa, técnica |
|
||||
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
|
||||
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
|
||||
| **Versões necessárias** | Dark mode e light mode |
|
||||
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
|
||||
| **Monocromática** | Deve funcionar em preto e branco puro |
|
||||
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
|
||||
|
||||
---
|
||||
|
||||
@@ -140,6 +151,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
|
||||
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
|
||||
|
||||
A logo deve transmitir:
|
||||
|
||||
- **Tecnologia** e **precisão**
|
||||
- **Conectividade** e **hub central**
|
||||
- **Profissionalismo** e **confiança**
|
||||
|
||||
@@ -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 { MakerDMG } from '@electron-forge/maker-dmg';
|
||||
import { MakerRpm } from '@electron-forge/maker-rpm';
|
||||
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
|
||||
import { MakerWix } from '@electron-forge/maker-wix';
|
||||
import { MakerZIP } from '@electron-forge/maker-zip';
|
||||
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
|
||||
@@ -441,10 +440,6 @@ const config: ForgeConfig = {
|
||||
},
|
||||
},
|
||||
makers: [
|
||||
new MakerSquirrel({
|
||||
setupIcon: 'images/icon.ico',
|
||||
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
|
||||
}),
|
||||
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
|
||||
? [
|
||||
new MakerWix({
|
||||
@@ -463,7 +458,7 @@ const config: ForgeConfig = {
|
||||
},
|
||||
['darwin'],
|
||||
),
|
||||
new MakerZIP({}, ['darwin']),
|
||||
new MakerZIP({}, ['darwin', 'win32']),
|
||||
appImageMaker,
|
||||
new MakerRpm({}),
|
||||
new MakerDeb({}),
|
||||
|
||||
+26
-3
@@ -1,10 +1,11 @@
|
||||
{
|
||||
"name": "gemini-nexus",
|
||||
"productName": "Gemini Nexus",
|
||||
"version": "0.13.0",
|
||||
"version": "0.15.3",
|
||||
"description": "Gemini Nexus - Electron App",
|
||||
"main": ".vite/build/main.js",
|
||||
"private": true,
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"start": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
"dev": "cross-env DEBUG=electron-forge:* electron-forge start",
|
||||
@@ -31,7 +32,6 @@
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-wix": "^7.9.0",
|
||||
"@electron-forge/maker-zip": "^7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
||||
@@ -170,9 +170,32 @@
|
||||
"@nestjs/core",
|
||||
"@sentry/cli",
|
||||
"@swc/core",
|
||||
"protobufjs"
|
||||
"protobufjs",
|
||||
"macos-alias",
|
||||
"better-sqlite3",
|
||||
"keytar",
|
||||
"electron",
|
||||
"electron-winstaller",
|
||||
"esbuild",
|
||||
"fs-xattr",
|
||||
"appdmg"
|
||||
],
|
||||
"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": {
|
||||
"node-abi": "^4.28.0",
|
||||
"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':
|
||||
specifier: ^7.11.1
|
||||
version: 7.11.1
|
||||
'@electron-forge/maker-squirrel':
|
||||
specifier: ^7.11.1
|
||||
version: 7.11.1
|
||||
'@electron-forge/maker-wix':
|
||||
specifier: ^7.9.0
|
||||
version: 7.11.1
|
||||
@@ -397,7 +394,7 @@ importers:
|
||||
version: 25.0.3([email protected])
|
||||
semantic-release-config-gitmoji:
|
||||
specifier: ^1.5.3
|
||||
version: 1.5.3(@octokit/core@7.0.6)([email protected])([email protected]([email protected]))
|
||||
version: 1.5.3(@octokit/core@4.2.4)([email protected])([email protected]([email protected]))
|
||||
sharp:
|
||||
specifier: ^0.34.5
|
||||
version: 0.34.5
|
||||
@@ -663,10 +660,6 @@ packages:
|
||||
resolution: {integrity: sha512-iEfJPRQQyaTqk2EbUfZgulChNWvxGXeYUH0xBX/r5cj1pL4vcJXt3jLMQBVn3mk/0Ytv9UWRs8R/XuNWX6sf2w==}
|
||||
engines: {node: '>= 16.4.0'}
|
||||
|
||||
'@electron-forge/[email protected]':
|
||||
resolution: {integrity: sha512-oSg7fgad6l+X0DjtRkSpMzB0AjzyDO4mb2gzM4kTodkP1ADeiMi08bxy0ZeCESqLm5+fG72cAPmEr3BAPvI1yw==}
|
||||
engines: {node: '>= 16.4.0'}
|
||||
|
||||
'@electron-forge/[email protected]':
|
||||
resolution: {integrity: sha512-yKsnfPQVBrjyjWndmSB0rqQSXAiIukwz5fuVyoCTnYpfJQojrEEMUN/eXSHA08d+rOPbB/hkMAR+QJcjJ8vO2Q==}
|
||||
engines: {node: '>= 16.4.0'}
|
||||
@@ -1160,183 +1153,155 @@ packages:
|
||||
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
|
||||
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@img/[email protected]':
|
||||
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
|
||||
@@ -2710,42 +2675,36 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rolldown/[email protected]':
|
||||
resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==}
|
||||
@@ -2836,79 +2795,66 @@ packages:
|
||||
resolution: {integrity: sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/[email protected]':
|
||||
resolution: {integrity: sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==}
|
||||
@@ -3180,42 +3126,36 @@ packages:
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-1SYGs2l0Yyyi0pR/P/NKz/x0kqxkoiw+BXeJjLUdecSk/KasncWlJrc6hOvFSgKHOBrzgM5jwuluKtlT8dnrcA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-TXREtiXeRhbfDFbmhnkIsXpKfzbfT73YkV2ZF6w0sfxgjC5zI2ZAbaCOq25qxvegofj2K93DtOpm9RLaBgqR2g==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-DCR2YYeyd6DQE4OuDhImouuNcjXEiEdnn1Y0DyGteugPEDvVuvYk8Xddi+4o2SgWH6jiW8/I+3emZvbep1NC+g==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-5Pizw3NgfOJ5BJOBK8TIRa59xFW2avESTOBDPTAYwZYa1JNDs+KMF9lUfjJiJLM5HiMs/wPheA9eiT0q9m2AoA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-qyqydP/wyH8alcIP4a2hnGSjHLJjm9H7yDFup+CPy9oTahFgLLwnNcv5UHXqO2Qs3AIND+cls5f/Bb6hqpxdgA==}
|
||||
engines: {node: '>=10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@swc/[email protected]':
|
||||
resolution: {integrity: sha512-CaQENgDHVGOg1mSF5sQVgvfFHG9kjMor2rkLMLeLOkfZYNj13ppnJ9+lfaBZLZUMMbnlGQnavCJb8PVBUOso7Q==}
|
||||
@@ -3292,28 +3232,24 @@ packages:
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==}
|
||||
engines: {node: '>= 20'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@tailwindcss/[email protected]':
|
||||
resolution: {integrity: sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==}
|
||||
@@ -4961,10 +4897,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-1sZTssferjgDgaqRTc0ieP+ozzpOy7LQTPTtEW3yQFn4+ORdIAZWV5BthXPyHF7YqLvFJCUPhNhdAJQYlYUgiw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-EYj1cm1nZoVHmIIx3o0aKt784lxdEpJnXbEnyypklUCnglqSb7ni+1xi1Vp/gtrGS/mzIxnWBT+x5fIfuDjhvA==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
@@ -5433,10 +5365,6 @@ packages:
|
||||
resolution: {integrity: sha512-CTXd6rk/M3/ULNQj8FBqBWHYBVYybQ3VPBw0xGKFe3tuH7ytT6ACnvzpIQ3UZtB8yvUKC2cXn1a+x+5EVQLovA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
@@ -6296,28 +6224,24 @@ packages:
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
|
||||
@@ -8107,10 +8031,6 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-C5tjlC/HCtVUOi3KWVokd4vHVViOmGjtLwIh4MuzPo/nMYTV/p1urt3RnMz2IWXDdKEGJH3k5+KPxtqRsUYGtg==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-d79HhZya5Djd7am0q+W4RTsSU+D/aJzM+4Y4AGJGuGlgM2L6sx5ZvOYTmZjqPhrDrV6xJTtRSm1JCLj6V6LHLQ==}
|
||||
engines: {node: '>=14.16'}
|
||||
@@ -8874,10 +8794,10 @@ snapshots:
|
||||
|
||||
'@acemir/[email protected]': {}
|
||||
|
||||
'@achingbrain/[email protected](@octokit/core@7.0.6)([email protected]([email protected]))':
|
||||
'@achingbrain/[email protected](@octokit/core@4.2.4)([email protected]([email protected]))':
|
||||
dependencies:
|
||||
'@octokit/plugin-retry': 3.0.9
|
||||
'@octokit/plugin-throttling': 3.7.0(@octokit/core@7.0.6)
|
||||
'@octokit/plugin-throttling': 3.7.0(@octokit/core@4.2.4)
|
||||
'@octokit/rest': 19.0.13
|
||||
'@semantic-release/error': 3.0.0
|
||||
aggregate-error: 3.1.0
|
||||
@@ -9269,17 +9189,6 @@ snapshots:
|
||||
- bluebird
|
||||
- supports-color
|
||||
|
||||
'@electron-forge/[email protected]':
|
||||
dependencies:
|
||||
'@electron-forge/maker-base': 7.11.1
|
||||
'@electron-forge/shared-types': 7.11.1
|
||||
fs-extra: 10.1.0
|
||||
optionalDependencies:
|
||||
electron-winstaller: 5.4.0
|
||||
transitivePeerDependencies:
|
||||
- bluebird
|
||||
- supports-color
|
||||
|
||||
'@electron-forge/[email protected]':
|
||||
dependencies:
|
||||
'@electron-forge/core-utils': 7.11.1
|
||||
@@ -10637,9 +10546,9 @@ snapshots:
|
||||
'@octokit/types': 16.0.0
|
||||
bottleneck: 2.19.5
|
||||
|
||||
'@octokit/[email protected](@octokit/core@7.0.6)':
|
||||
'@octokit/[email protected](@octokit/core@4.2.4)':
|
||||
dependencies:
|
||||
'@octokit/core': 7.0.6
|
||||
'@octokit/core': 4.2.4
|
||||
'@octokit/types': 6.41.0
|
||||
bottleneck: 2.19.5
|
||||
|
||||
@@ -14036,19 +13945,6 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@electron/asar': 3.4.1
|
||||
debug: 4.4.3
|
||||
fs-extra: 7.0.1
|
||||
lodash: 4.18.1
|
||||
temp: 0.9.4
|
||||
optionalDependencies:
|
||||
'@electron/windows-sign': 1.2.2
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
'@electron/windows-sign': 1.2.2
|
||||
@@ -14730,13 +14626,6 @@ snapshots:
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 4.0.0
|
||||
universalify: 0.1.2
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -16944,12 +16833,12 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected](@octokit/core@7.0.6)([email protected])([email protected]([email protected])):
|
||||
[email protected](@octokit/core@4.2.4)([email protected])([email protected]([email protected])):
|
||||
dependencies:
|
||||
'@gitmoji/commit-types': 1.1.5
|
||||
'@semantic-release/changelog': 6.0.3([email protected]([email protected]))
|
||||
'@semantic-release/git': 10.0.1([email protected]([email protected]))
|
||||
'@semantic-release/github': '@achingbrain/[email protected](@octokit/core@7.0.6)([email protected]([email protected]))'
|
||||
'@semantic-release/github': '@achingbrain/[email protected](@octokit/core@4.2.4)([email protected]([email protected]))'
|
||||
'@semrel-extra/npm': 1.2.2
|
||||
'@types/semantic-release': 17.2.11
|
||||
conventional-changelog-gitmoji-config: 1.5.2([email protected])
|
||||
@@ -17472,12 +17361,6 @@ snapshots:
|
||||
async-exit-hook: 2.0.1
|
||||
fs-extra: 10.1.0
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
mkdirp: 0.5.6
|
||||
rimraf: 2.6.3
|
||||
optional: true
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
is-stream: 3.0.0
|
||||
|
||||
@@ -8,5 +8,8 @@ allowBuilds:
|
||||
electron-winstaller: true
|
||||
esbuild: true
|
||||
keytar: true
|
||||
macos-alias: true
|
||||
protobufjs: 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).
|
||||
*/
|
||||
|
||||
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
|
||||
const REPO_URL = 'https://github.com/evandrodevbr/GeminiNexus';
|
||||
const GITHUB_BASE_URL = 'https://github.com';
|
||||
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
|
||||
|
||||
@@ -244,7 +244,7 @@ const releaseConfig = {
|
||||
[
|
||||
'@semantic-release/git',
|
||||
{
|
||||
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
|
||||
assets: ['CHANGELOG.md', 'package.json', 'pnpm-lock.yaml'],
|
||||
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
|
||||
},
|
||||
],
|
||||
|
||||
-118
@@ -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 %*
|
||||
+75
-24
@@ -41,7 +41,9 @@ function color(code, text) {
|
||||
}
|
||||
|
||||
function header(text) {
|
||||
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
|
||||
console.log(
|
||||
`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`,
|
||||
);
|
||||
}
|
||||
|
||||
function success(text) {
|
||||
@@ -145,10 +147,13 @@ function checkWindows() {
|
||||
|
||||
for (const vswhere of vswherePaths) {
|
||||
try {
|
||||
const output = execSync(`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`, {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
const output = execSync(
|
||||
`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`,
|
||||
{
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
},
|
||||
).trim();
|
||||
|
||||
if (output) {
|
||||
vsFound = true;
|
||||
@@ -163,10 +168,9 @@ function checkWindows() {
|
||||
// Check registry fallback
|
||||
if (!vsFound) {
|
||||
try {
|
||||
execSync(
|
||||
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
|
||||
{ stdio: 'ignore' },
|
||||
);
|
||||
execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0', {
|
||||
stdio: 'ignore',
|
||||
});
|
||||
vsFound = true;
|
||||
success('Visual Studio 2022 found (registry)');
|
||||
} catch {
|
||||
@@ -198,7 +202,10 @@ function checkMacOS() {
|
||||
const results = { ok: true, missing: [] };
|
||||
|
||||
try {
|
||||
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
||||
const xcodePath = execSync('xcode-select -p', {
|
||||
stdio: ['ignore', 'pipe', 'ignore'],
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
if (xcodePath) {
|
||||
success(`Xcode CLT found at: ${xcodePath}`);
|
||||
}
|
||||
@@ -323,14 +330,26 @@ async function installWindows() {
|
||||
// Check if winget is available
|
||||
if (!commandExists('winget')) {
|
||||
console.log('');
|
||||
console.log(color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'));
|
||||
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
||||
console.log(
|
||||
color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'),
|
||||
);
|
||||
console.log(
|
||||
color(
|
||||
CYAN,
|
||||
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
|
||||
),
|
||||
);
|
||||
console.log('');
|
||||
console.log(' During installation, select:');
|
||||
console.log(color(BOLD, ' "Desktop development with C++" workload'));
|
||||
console.log('');
|
||||
console.log(' Or install via PowerShell (as Administrator):');
|
||||
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
||||
console.log(
|
||||
color(
|
||||
DIM,
|
||||
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -398,7 +417,9 @@ async function installLinux() {
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'apt-get', 'install', '-y',
|
||||
'apt-get',
|
||||
'install',
|
||||
'-y',
|
||||
'build-essential',
|
||||
'python3',
|
||||
'libsecret-1-dev',
|
||||
@@ -421,7 +442,10 @@ async function installLinux() {
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'pacman', '-S', '--needed', '--noconfirm',
|
||||
'pacman',
|
||||
'-S',
|
||||
'--needed',
|
||||
'--noconfirm',
|
||||
'base-devel',
|
||||
'python',
|
||||
'libsecret',
|
||||
@@ -443,7 +467,9 @@ async function installLinux() {
|
||||
}
|
||||
|
||||
const status = await runCommand('sudo', [
|
||||
'dnf', 'install', '-y',
|
||||
'dnf',
|
||||
'install',
|
||||
'-y',
|
||||
'gcc-c++',
|
||||
'make',
|
||||
'python3',
|
||||
@@ -471,7 +497,9 @@ async function installLinux() {
|
||||
console.log(' - libsecret development headers (for keytar)');
|
||||
console.log(' - pkg-config');
|
||||
console.log('');
|
||||
console.log(' Install them using your distribution package manager, then run npm install again.');
|
||||
console.log(
|
||||
' Install them using your distribution package manager, then run npm install again.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -520,7 +548,9 @@ async function main() {
|
||||
|
||||
// ── Missing tools ──
|
||||
console.log('');
|
||||
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
|
||||
error(
|
||||
'Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.',
|
||||
);
|
||||
|
||||
if (checkOnly) {
|
||||
console.log('');
|
||||
@@ -528,7 +558,9 @@ async function main() {
|
||||
warn('Run "npm run setup" to install them automatically, or install manually:');
|
||||
printManualInstructions(platform, results.missing);
|
||||
console.log('');
|
||||
dim('npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.');
|
||||
dim(
|
||||
'npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.',
|
||||
);
|
||||
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
|
||||
return;
|
||||
}
|
||||
@@ -572,13 +604,23 @@ function printManualInstructions(platform, missing) {
|
||||
switch (platform) {
|
||||
case 'win32':
|
||||
console.log(' 1. Download Visual Studio 2022 Build Tools:');
|
||||
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
||||
console.log(
|
||||
color(
|
||||
CYAN,
|
||||
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
|
||||
),
|
||||
);
|
||||
console.log('');
|
||||
console.log(' 2. Run the installer and select:');
|
||||
console.log(color(BOLD, ' "Desktop development with C++"'));
|
||||
console.log('');
|
||||
console.log(' 3. Or install via PowerShell (as Administrator):');
|
||||
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
||||
console.log(
|
||||
color(
|
||||
DIM,
|
||||
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
|
||||
),
|
||||
);
|
||||
console.log('');
|
||||
console.log(' 4. Restart your terminal after installation.');
|
||||
break;
|
||||
@@ -594,13 +636,22 @@ function printManualInstructions(platform, missing) {
|
||||
const distro = detectLinuxDistro();
|
||||
if (distro === 'debian') {
|
||||
console.log(' Run:');
|
||||
console.log(color(DIM, ' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config'));
|
||||
console.log(
|
||||
color(
|
||||
DIM,
|
||||
' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config',
|
||||
),
|
||||
);
|
||||
} else if (distro === 'arch') {
|
||||
console.log(' Run:');
|
||||
console.log(color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'));
|
||||
console.log(
|
||||
color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'),
|
||||
);
|
||||
} else if (distro === 'fedora') {
|
||||
console.log(' Run:');
|
||||
console.log(color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'));
|
||||
console.log(
|
||||
color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'),
|
||||
);
|
||||
} else {
|
||||
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
|
||||
}
|
||||
|
||||
@@ -114,8 +114,10 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
||||
<Card
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all duration-200',
|
||||
|
||||
isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
|
||||
|
||||
isCurrent
|
||||
? 'border-primary/50 bg-primary/10'
|
||||
: 'hover:bg-accent/10 cursor-pointer border-white/5',
|
||||
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
||||
)}
|
||||
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
||||
|
||||
+305
-257
@@ -235,54 +235,51 @@ export function CloudAccountCard({
|
||||
const renderQuotaModelGroup = (title: string, models: ModelQuotaEntry[]) => {
|
||||
if (models.length === 0) return null;
|
||||
return (
|
||||
<div key={title} className="space-y-1">
|
||||
<div className="flex items-center gap-1.5 px-2 py-1">
|
||||
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
|
||||
{title}
|
||||
</span>
|
||||
<div className="h-[1px] flex-1 bg-white/[0.04]" />
|
||||
</div>
|
||||
{models.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-[13px] transition-all hover:bg-white/[0.03]"
|
||||
>
|
||||
<span
|
||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||
title={modelName}
|
||||
<div key={title} className="mt-1 space-y-1">
|
||||
<h4 className="text-muted-foreground/60 mb-1.5 px-1 text-[10px] font-bold tracking-widest uppercase">
|
||||
{title}
|
||||
</h4>
|
||||
<div className="flex flex-col gap-1">
|
||||
{models.map(([modelName, info]) => (
|
||||
<div
|
||||
key={modelName}
|
||||
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 rounded-md px-1.5 py-1 text-[13px] transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
<div className="flex flex-col items-end gap-0.5">
|
||||
<span
|
||||
className="text-muted-foreground text-[9px] leading-none opacity-80"
|
||||
title={formatResetTimeTitleText(info.resetTime)}
|
||||
className="text-muted-foreground group-hover/item:text-foreground truncate font-medium transition-colors"
|
||||
title={modelName}
|
||||
>
|
||||
{formatResetTimeLabelText(info.resetTime)}
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono text-xs leading-none font-bold ${getQuotaTextColorClass(info.percentage)}`}
|
||||
className="text-muted-foreground/70 min-w-[50px] text-right text-[9px]"
|
||||
title={formatResetTimeTitleText(info.resetTime)}
|
||||
>
|
||||
{formatResetTimeLabelText(info.resetTime)}
|
||||
</span>
|
||||
<span
|
||||
className={`w-9 text-right font-mono text-xs font-bold ${getQuotaTextColorClass(info.percentage)}`}
|
||||
>
|
||||
{info.percentage}%
|
||||
</span>
|
||||
<div className="bg-muted h-1 w-16 overflow-hidden rounded-full">
|
||||
<div className="h-1.5 w-12 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const emptyQuotaState = (
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-4">
|
||||
<Box className="mb-2 h-8 w-8 opacity-20" />
|
||||
<div className="text-muted-foreground flex flex-col items-center justify-center py-6">
|
||||
<Box className="mb-2 h-6 w-6 opacity-20" />
|
||||
<span className="text-xs">{t('cloud.card.noQuota')}</span>
|
||||
</div>
|
||||
);
|
||||
@@ -290,18 +287,20 @@ export function CloudAccountCard({
|
||||
const providerStats = providerGroupingsEnabled ? getAccountStats(account) : null;
|
||||
const providerGroupedQuotaSection =
|
||||
providerStats && providerStats.visibleModels > 0 ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between rounded-lg bg-white/[0.03] px-3 py-1.5 text-xs">
|
||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border border-white/[0.04] bg-white/[0.02] px-3 py-2">
|
||||
<span className="text-muted-foreground text-xs font-medium">
|
||||
{t('settings.providerGroupings.overall')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`font-mono font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||
className={`font-mono text-xs font-bold ${getQuotaTextColorClass(providerStats.overallPercentage)}`}
|
||||
>
|
||||
{formatQuotaLabel(providerStats.overallPercentage)}
|
||||
</span>
|
||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
||||
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
|
||||
className={`h-full rounded-full transition-all duration-500 ease-out ${getQuotaBarColorClass(providerStats.overallPercentage)}`}
|
||||
style={{
|
||||
width: `${clampQuotaPercentage(providerStats.overallPercentage)}%`,
|
||||
}}
|
||||
@@ -310,7 +309,7 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
<div className="space-y-3">
|
||||
{providerStats.providers.map((group) => (
|
||||
<ProviderGroup
|
||||
key={group.providerKey}
|
||||
@@ -326,7 +325,7 @@ export function CloudAccountCard({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : (
|
||||
emptyQuotaState
|
||||
);
|
||||
@@ -343,17 +342,17 @@ export function CloudAccountCard({
|
||||
|
||||
return (
|
||||
<Card
|
||||
className={`group flex h-full flex-col overflow-hidden rounded-xl border border-white/[0.06] bg-card transition-all duration-200 hover:border-white/[0.12] ${isSelected ? 'ring-accent/30 border-accent/40 ring-2' : ''}`}
|
||||
className={`group bg-card relative flex h-full flex-col overflow-hidden rounded-xl border transition-all duration-300 hover:shadow-md ${isSelected ? 'border-primary/50 ring-primary/50 ring-1' : 'border-white/[0.06] hover:border-white/[0.15]'}`}
|
||||
>
|
||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||
<CardHeader className="flex flex-row items-start gap-4 space-y-0 p-5 pb-4">
|
||||
{onToggleSelection && (
|
||||
<div
|
||||
className={`absolute top-2 left-2 z-10 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'} bg-background/90 rounded-full p-2 transition-opacity`}
|
||||
className={`absolute top-3 left-3 z-10 transition-opacity duration-200 ${isSelected ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'}`}
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(checked) => onToggleSelection(account.id, checked as boolean)}
|
||||
className="h-5 w-5 border-2"
|
||||
className="bg-background/80 data-[state=checked]:bg-primary h-4 w-4 backdrop-blur-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -362,188 +361,193 @@ export function CloudAccountCard({
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-10 w-10 rounded-full border"
|
||||
className="h-11 w-11 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground">
|
||||
<div className="text-muted-foreground flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-lg font-medium">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<CardTitle className="truncate text-base font-semibold">
|
||||
|
||||
<div className="flex flex-1 flex-col gap-0.5 overflow-hidden pt-0.5">
|
||||
<CardTitle className="truncate text-base leading-tight font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</CardTitle>
|
||||
<CardDescription className="truncate text-xs">{account.email}</CardDescription>
|
||||
<CardDescription className="text-muted-foreground/80 truncate text-xs font-medium">
|
||||
{account.email}
|
||||
</CardDescription>
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<div className="mt-1 flex items-center gap-1 text-[10px] font-medium text-blue-500">
|
||||
<span>
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
</span>
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground opacity-70">
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
);
|
||||
})()}
|
||||
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.useAccount')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.refresh')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.delete')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 pb-2">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="mt-1 flex flex-wrap items-center gap-1.5">
|
||||
<Badge
|
||||
variant={
|
||||
account.status === 'rate_limited' || account.status === 'expired'
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="text-xs"
|
||||
className="h-4 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
{account.provider}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="h-4 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase hover:bg-emerald-500/20"
|
||||
>
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive text-xs font-medium">
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
{hasHighTier && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="h-4 animate-pulse border-blue-500/20 bg-blue-500/10 px-1.5 py-0 text-[9px] text-blue-400 uppercase"
|
||||
>
|
||||
{t('cloud.card.gemini3Ready')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasHighTier && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="animate-pulse border-blue-500/20 bg-blue-500/10 text-[10px] text-blue-500"
|
||||
>
|
||||
{t('cloud.card.gemini3Ready')}
|
||||
</Badge>
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive mt-1 text-xs font-medium">
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100">
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<div className="mt-2 flex w-fit items-center gap-1.5 rounded-md border border-blue-500/20 bg-blue-500/10 px-2 py-1">
|
||||
<span className="text-[10px] font-semibold text-blue-400">
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
</span>
|
||||
{aiCredits.expiryDate && (
|
||||
<>
|
||||
<span className="h-2.5 w-[1px] bg-blue-500/30"></span>
|
||||
<span className="text-[9px] font-medium text-blue-400/80">
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
|
||||
>
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
);
|
||||
})()}
|
||||
<span className="sr-only">{t('cloud.card.modelVisibility')}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel className="text-xs">
|
||||
{t('cloud.card.modelVisibility')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[300px] overflow-y-auto p-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
onCheckedChange={(checked) => {
|
||||
if (config) {
|
||||
const newVisibility = { ...config.model_visibility };
|
||||
newVisibility[modelName] = checked as boolean;
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate text-xs font-medium" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground/70 hover:text-foreground h-7 w-7"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
<span className="font-medium">{t('cloud.card.useAccount')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRefresh(account.id)}
|
||||
disabled={isRefreshing}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
<span>{t('cloud.card.refresh')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onManageIdentity(account.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
<span>{t('cloud.card.identityProfile')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
<span>{t('cloud.card.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 px-5 pb-5">
|
||||
<div className="space-y-4">
|
||||
{providerGroupingsEnabled ? (
|
||||
providerGroupedQuotaSection
|
||||
) : hasVisibleQuotaModels ? (
|
||||
<div className="space-y-3">
|
||||
{renderQuotaModelGroup(t('cloud.card.groupGoogleGemini'), geminiModels)}
|
||||
<div className="pt-1" />
|
||||
{renderQuotaModelGroup(t('cloud.card.groupAnthropicClaude'), claudeModels)}
|
||||
{renderQuotaModelGroup('Google Gemini', geminiModels)}
|
||||
{claudeModels.length > 0 && renderQuotaModelGroup('Anthropic Claude', claudeModels)}
|
||||
</div>
|
||||
) : (
|
||||
emptyQuotaState
|
||||
@@ -551,11 +555,36 @@ export function CloudAccountCard({
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground">
|
||||
<span>
|
||||
<CardFooter className="flex items-center justify-between border-t border-white/[0.04] bg-white/[0.01] px-5 py-3">
|
||||
<span className="text-muted-foreground/70 text-[11px] font-medium">
|
||||
{t('cloud.card.used')}{' '}
|
||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||
</span>
|
||||
|
||||
{account.is_active ? (
|
||||
<span className="flex items-center gap-1.5 text-xs font-semibold text-emerald-400">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-2 w-2 rounded-full bg-emerald-500"></span>
|
||||
</span>
|
||||
Active
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="hover:bg-primary/10 hover:text-primary h-7 cursor-pointer px-3 text-xs font-medium transition-colors"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="mr-1.5 h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1.5 h-3 w-3" />
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
)}
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
@@ -613,22 +642,22 @@ export function CompactCloudAccountCard({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-card px-3 py-2 transition-all duration-200 hover:border-white/[0.12]">
|
||||
<div className="group bg-card flex items-center gap-3 rounded-xl border border-white/[0.04] px-4 py-3 transition-all duration-200 hover:border-white/[0.1] hover:shadow-sm">
|
||||
{account.avatar_url ? (
|
||||
<img
|
||||
src={account.avatar_url}
|
||||
alt={account.name || ''}
|
||||
className="bg-muted h-7 w-7 rounded-full border"
|
||||
className="h-8 w-8 shrink-0 rounded-full border border-white/[0.08] bg-white/[0.02] object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground">
|
||||
<div className="text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-white/[0.08] bg-white/[0.02] text-xs font-semibold">
|
||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-[13px] font-semibold">
|
||||
<div className="mb-0.5 flex items-center gap-2">
|
||||
<span className="text-foreground truncate text-[13px] leading-none font-semibold">
|
||||
{account.name || t('cloud.card.unknown')}
|
||||
</span>
|
||||
<Badge
|
||||
@@ -637,63 +666,62 @@ export function CompactCloudAccountCard({
|
||||
? 'destructive'
|
||||
: 'outline'
|
||||
}
|
||||
className="shrink-0 text-[10px]"
|
||||
className="h-4 shrink-0 border-white/[0.1] bg-white/[0.02] px-1.5 py-0 text-[9px] uppercase"
|
||||
>
|
||||
{account.provider.toUpperCase()}
|
||||
{account.provider}
|
||||
</Badge>
|
||||
{account.is_active && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20"
|
||||
className="h-4 shrink-0 border border-emerald-500/20 bg-emerald-500/10 px-1.5 py-0 text-[9px] text-emerald-400 uppercase"
|
||||
>
|
||||
{t('cloud.card.active')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-muted-foreground flex items-center gap-3 text-xs">
|
||||
<div className="text-muted-foreground/80 flex items-center gap-3 text-[11px] font-medium">
|
||||
<span className="truncate">{account.email}</span>
|
||||
{validationBlockedStatusLabel && (
|
||||
<span className="text-destructive shrink-0 font-medium">
|
||||
{validationBlockedStatusLabel}
|
||||
</span>
|
||||
<span className="text-destructive shrink-0">{validationBlockedStatusLabel}</span>
|
||||
)}
|
||||
|
||||
{shouldShowAiCredits && aiCredits && (
|
||||
<span className="shrink-0 text-blue-500">
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
<span className="flex shrink-0 items-center gap-1.5 text-blue-400">
|
||||
<span>
|
||||
{t('cloud.card.aiCreditsValue', {
|
||||
amount: formatAiCreditsAmount(aiCredits.credits),
|
||||
})}
|
||||
</span>
|
||||
{aiCredits.expiryDate && (
|
||||
<span className="text-muted-foreground">
|
||||
{' '}
|
||||
·{' '}
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
<>
|
||||
<span className="h-2.5 w-[1px] bg-blue-500/30"></span>
|
||||
<span className="text-blue-400/70">
|
||||
{t('cloud.card.creditsExpiry', {
|
||||
date: formatCreditsExpiry(aiCredits.expiryDate),
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{compactModels.length > 0 && (
|
||||
<div className="mt-1 flex items-center gap-1">
|
||||
<div className="mt-2 flex items-center gap-1.5">
|
||||
{compactModels.map(([modelName, info]) => (
|
||||
<TooltipProvider key={modelName}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted h-1.5 w-12 overflow-hidden rounded-full">
|
||||
<div className="h-1.5 w-10 overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(info.percentage)}`}
|
||||
className={`h-full rounded-full transition-all duration-300 ease-out ${getQuotaBarColorClass(info.percentage)}`}
|
||||
style={{ width: `${clampQuotaPercentage(info.percentage)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="text-xs">
|
||||
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||
</p>
|
||||
<TooltipContent side="bottom" className="text-xs font-medium">
|
||||
{formatModelDisplayName(modelName)}: {info.percentage}%
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
@@ -706,29 +734,36 @@ export function CompactCloudAccountCard({
|
||||
{allModelEntries.length > 0 && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
|
||||
>
|
||||
{(() => {
|
||||
const hiddenCount = allModelEntries.filter(
|
||||
([modelName]) => config?.model_visibility?.[modelName] === false,
|
||||
).length;
|
||||
return hiddenCount > 0 ? (
|
||||
<EyeOff className="text-muted-foreground h-3.5 w-3.5" />
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
<Eye className="h-4 w-4" />
|
||||
);
|
||||
})()}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-64" align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.modelVisibility')}</DropdownMenuLabel>
|
||||
<div className="max-h-64 overflow-auto px-2 py-1">
|
||||
<DropdownMenuContent className="w-56" align="end">
|
||||
<DropdownMenuLabel className="text-xs">
|
||||
{t('cloud.card.modelVisibility')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="max-h-[250px] overflow-y-auto p-1">
|
||||
{allModelEntries.map(([modelName]) => {
|
||||
const isVisible = config?.model_visibility?.[modelName] !== false;
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={modelName}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
className="flex cursor-pointer items-center gap-2"
|
||||
className="flex cursor-pointer items-center gap-2.5 rounded-sm px-2 py-1.5"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isVisible}
|
||||
@@ -739,8 +774,9 @@ export function CompactCloudAccountCard({
|
||||
saveConfig({ ...config, model_visibility: newVisibility });
|
||||
}
|
||||
}}
|
||||
className="h-3.5 w-3.5"
|
||||
/>
|
||||
<span className="truncate text-xs" title={modelName}>
|
||||
<span className="truncate text-xs font-medium" title={modelName}>
|
||||
{formatModelDisplayName(modelName)}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
@@ -752,27 +788,27 @@ export function CompactCloudAccountCard({
|
||||
)}
|
||||
|
||||
{account.is_active ? (
|
||||
<div className="flex h-8 items-center px-2">
|
||||
<span className="flex items-center gap-1.5 text-[11px] font-bold text-emerald-400">
|
||||
<span className="relative flex h-1.5 w-1.5">
|
||||
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-400 opacity-75"></span>
|
||||
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500"></span>
|
||||
</span>
|
||||
Active
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled
|
||||
className="h-7 text-xs text-emerald-400 opacity-100"
|
||||
>
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
{t('cloud.card.active')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="h-7 cursor-pointer text-xs"
|
||||
className="hover:bg-primary/10 hover:text-primary h-8 cursor-pointer px-3 text-xs font-medium transition-colors"
|
||||
>
|
||||
{isSwitching ? (
|
||||
<RefreshCw className="h-3 w-3 animate-spin" />
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Power className="mr-1 h-3 w-3" />
|
||||
<Power className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
{t('cloud.card.use')}
|
||||
</Button>
|
||||
@@ -780,35 +816,47 @@ export function CompactCloudAccountCard({
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 cursor-pointer rounded-full">
|
||||
<MoreVertical className="h-3.5 w-3.5" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground/70 hover:text-foreground h-8 w-8"
|
||||
>
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
<span className="sr-only">Menu</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>{t('cloud.card.actions')}</DropdownMenuLabel>
|
||||
<DropdownMenuItem onClick={() => onSwitch(account.id)} disabled={isSwitching}>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem
|
||||
onClick={() => onSwitch(account.id)}
|
||||
disabled={isSwitching}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Power className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.useAccount')}
|
||||
<span className="font-medium">{t('cloud.card.useAccount')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onRefresh(account.id)} disabled={isRefreshing}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onRefresh(account.id)}
|
||||
disabled={isRefreshing}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.refresh')}
|
||||
<span>{t('cloud.card.refresh')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={() => onManageIdentity(account.id)}>
|
||||
<DropdownMenuItem
|
||||
onClick={() => onManageIdentity(account.id)}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<Fingerprint className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.identityProfile')}
|
||||
<span>{t('cloud.card.identityProfile')}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => onDelete(account.id)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
className="text-destructive focus:bg-destructive/10 focus:text-destructive cursor-pointer"
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash className="mr-2 h-4 w-4" />
|
||||
{t('cloud.card.delete')}
|
||||
<span>{t('cloud.card.delete')}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
+634
-1159
File diff suppressed because it is too large.
Load diff
@@ -134,7 +134,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
|
||||
setTimeout(() => {
|
||||
void refreshProfiles();
|
||||
}, 0);
|
||||
}, [open, refreshProfiles]);
|
||||
|
||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||
if (actionLockRef.current) {
|
||||
@@ -343,7 +346,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
||||
|
||||
{!showLoadingPlaceholder
|
||||
? history.map((version) => (
|
||||
<div key={version.id} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
|
||||
<div
|
||||
key={version.id}
|
||||
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3"
|
||||
>
|
||||
<div className="mb-2 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-medium">{version.label}</div>
|
||||
|
||||
@@ -48,8 +48,12 @@ export function ModelVisibilitySettings() {
|
||||
|
||||
// Initialize model visibility and provider groupings from config
|
||||
useEffect(() => {
|
||||
if (config?.model_visibility) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
|
||||
if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
|
||||
if (config?.model_visibility) {
|
||||
setTimeout(() => setModelVisibilityMap(config.model_visibility), 0);
|
||||
}
|
||||
if (config?.provider_groupings_enabled !== undefined) {
|
||||
setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0);
|
||||
}
|
||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||
|
||||
// Get all unique models from all accounts
|
||||
@@ -184,7 +188,7 @@ export function ModelVisibilitySettings() {
|
||||
return (
|
||||
<div
|
||||
key={modelId}
|
||||
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
|
||||
className="flex items-center space-x-3 rounded-lg p-2 transition-colors hover:bg-white/[0.03]"
|
||||
>
|
||||
<Checkbox
|
||||
id={`model-${modelId}`}
|
||||
|
||||
@@ -3,15 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Play, Square, Loader2, Power } from 'lucide-react';
|
||||
import { Play, Square, Loader2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface StatusBarProps {
|
||||
isCollapsed?: boolean;
|
||||
}
|
||||
|
||||
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
|
||||
export const StatusBar: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -44,112 +38,48 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
||||
};
|
||||
|
||||
const isPending = startMutation.isPending || stopMutation.isPending;
|
||||
|
||||
// Collapsed View
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<div className="flex justify-center py-2">
|
||||
<TooltipProvider>
|
||||
<Tooltip delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||
isRunning
|
||||
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
||||
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Power className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
||||
<div
|
||||
className={cn(
|
||||
'h-1.5 w-1.5 rounded-full',
|
||||
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Expanded View
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
|
||||
isRunning
|
||||
? 'bg-emerald-500/[0.08]'
|
||||
: 'bg-white/[0.02]',
|
||||
<div className="flex items-center gap-2 rounded-md border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||
{isLoading ? (
|
||||
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 rounded-full',
|
||||
isRunning
|
||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
|
||||
isRunning
|
||||
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||
: 'bg-muted-foreground/50',
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||
Status
|
||||
</span>
|
||||
<span className={cn(
|
||||
'text-xs font-medium leading-tight',
|
||||
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
||||
)}>
|
||||
{isLoading
|
||||
? t('status.checking')
|
||||
: isRunning
|
||||
? t('status.running')
|
||||
: t('status.stopped')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
>
|
||||
{isLoading ? '...' : isRunning ? 'Running' : 'Stopped'}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggle}
|
||||
disabled={isLoading || isPending}
|
||||
className={cn(
|
||||
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
|
||||
'h-7 w-7 rounded-md p-0',
|
||||
isRunning
|
||||
? 'text-emerald-400 hover:bg-emerald-500/10'
|
||||
: 'text-muted-foreground hover:bg-white/[0.06]',
|
||||
)}
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : isRunning ? (
|
||||
<Square className="h-3 w-3 fill-current" />
|
||||
<Square className="h-3.5 w-3.5 fill-current" />
|
||||
) : (
|
||||
<Play className="h-3 w-3 fill-current" />
|
||||
<Play className="h-3.5 w-3.5 fill-current" />
|
||||
)}
|
||||
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
|
||||
<span className="sr-only">{isRunning ? t('action.stop') : t('action.start')}</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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="draglayer w-full">
|
||||
{title && !isMacOS && (
|
||||
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-muted-foreground select-none">
|
||||
<div className="text-muted-foreground flex flex-1 p-2 text-xs whitespace-nowrap select-none">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { MonitorTab } from './advanced/MonitorTab';
|
||||
import { ToolsTab } from './advanced/ToolsTab';
|
||||
import { ReplayTab } from './advanced/ReplayTab';
|
||||
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
|
||||
import type { ProxyConfig } from '@/types/config';
|
||||
|
||||
interface ProxyAdvancedTabProps {
|
||||
proxyConfig: ProxyConfig;
|
||||
}
|
||||
|
||||
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="monitor" className="space-y-5">
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
|
||||
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
|
||||
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
|
||||
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="monitor">
|
||||
<MonitorTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="tools">
|
||||
<ToolsTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="replay">
|
||||
<ReplayTab />
|
||||
</TabsContent>
|
||||
<TabsContent value="capabilities">
|
||||
<CapabilitiesTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
@@ -98,7 +98,7 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
<h3 className="text-base font-semibold">
|
||||
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
|
||||
</h3>
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -113,7 +113,7 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
{/* Content */}
|
||||
{isLoading ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
@@ -121,11 +121,11 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
<div key={provider} className="space-y-3">
|
||||
{/* Provider header */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-[13px] font-semibold tracking-wide uppercase text-muted-foreground">
|
||||
<h4 className="text-muted-foreground text-[13px] font-semibold tracking-wide uppercase">
|
||||
{provider}
|
||||
</h4>
|
||||
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
|
||||
<span className="text-muted-foreground/60 text-[11px] tabular-nums">
|
||||
{groupedModels[provider].length}
|
||||
</span>
|
||||
</div>
|
||||
@@ -140,23 +140,23 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
return (
|
||||
<div
|
||||
key={model.id}
|
||||
className="group flex flex-col rounded-xl border border-white/[0.06] bg-card p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
||||
className="group bg-card flex flex-col rounded-xl border border-white/[0.06] p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
||||
>
|
||||
{/* Model header */}
|
||||
<div className="mb-3 space-y-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<h5
|
||||
className="text-[13px] font-semibold leading-tight truncate"
|
||||
className="truncate text-[13px] leading-tight font-semibold"
|
||||
title={model.displayName || model.id}
|
||||
>
|
||||
{model.displayName || model.id}
|
||||
</h5>
|
||||
<span className="shrink-0 text-[10px] font-medium text-muted-foreground/60 rounded-md bg-white/[0.04] px-1.5 py-0.5">
|
||||
<span className="text-muted-foreground/60 shrink-0 rounded-md bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium">
|
||||
{enabledCount}/{totalCount}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
className="font-mono text-[11px] text-muted-foreground truncate"
|
||||
className="text-muted-foreground truncate font-mono text-[11px]"
|
||||
title={model.id}
|
||||
>
|
||||
{model.id}
|
||||
@@ -164,7 +164,7 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Capability pills */}
|
||||
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||
const Icon = CAPABILITY_ICONS[iconKey];
|
||||
@@ -174,8 +174,8 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
key={key}
|
||||
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
|
||||
supported
|
||||
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/15'
|
||||
: 'bg-white/[0.02] text-muted-foreground/50 border border-white/[0.04]'
|
||||
? 'border border-emerald-500/15 bg-emerald-500/10 text-emerald-400'
|
||||
: 'text-muted-foreground/50 border border-white/[0.04] bg-white/[0.02]'
|
||||
}`}
|
||||
title={label}
|
||||
>
|
||||
@@ -190,17 +190,17 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
{hasLimits && (
|
||||
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
|
||||
{model.limits.maxTokens != null && (
|
||||
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
|
||||
CTX {formatTokenCount(model.limits.maxTokens)}
|
||||
</span>
|
||||
)}
|
||||
{model.limits.maxOutputTokens != null && (
|
||||
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
|
||||
OUT {formatTokenCount(model.limits.maxOutputTokens)}
|
||||
</span>
|
||||
)}
|
||||
{model.limits.thinkingBudget != null && (
|
||||
<span className="inline-flex items-center rounded-md bg-blue-500/10 border border-blue-500/15 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
||||
<span className="inline-flex items-center rounded-md border border-blue-500/15 bg-blue-500/10 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
||||
THINK {formatTokenCount(model.limits.thinkingBudget)}
|
||||
</span>
|
||||
)}
|
||||
@@ -215,12 +215,10 @@ export const CapabilitiesTab: React.FC = () => {
|
||||
|
||||
{models.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/[0.1] py-16 text-center">
|
||||
<Layers className="h-8 w-8 text-muted-foreground/30" />
|
||||
<Layers className="text-muted-foreground/30 h-8 w-8" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-[13px] font-medium">No models found</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Check your provider configurations
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">Check your provider configurations</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -54,7 +54,8 @@ export const ReplayTab: React.FC = () => {
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: t('proxy.advanced.replay.error'),
|
||||
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||
description:
|
||||
error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
setSelectedRequestId(null);
|
||||
@@ -76,7 +77,10 @@ export const ReplayTab: React.FC = () => {
|
||||
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
||||
if (status >= 200 && status < 300) {
|
||||
return (
|
||||
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
|
||||
<Badge
|
||||
variant="default"
|
||||
className="border-emerald-500/20 bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20"
|
||||
>
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
{status}
|
||||
</Badge>
|
||||
@@ -99,7 +103,9 @@ export const ReplayTab: React.FC = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
|
||||
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.advanced.replay.description')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -113,10 +119,12 @@ export const ReplayTab: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* Requests List */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
||||
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="space-y-1 border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
|
||||
<p className="text-xs text-muted-foreground">{t('proxy.advanced.replay.clickToReplay')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('proxy.advanced.replay.clickToReplay')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{requestsLoading ? (
|
||||
@@ -125,8 +133,8 @@ export const ReplayTab: React.FC = () => {
|
||||
</div>
|
||||
) : requests.length === 0 ? (
|
||||
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-white/[0.1]">
|
||||
<Clock className="h-6 w-6 text-muted-foreground/30" />
|
||||
<p className="text-[13px] text-muted-foreground">
|
||||
<Clock className="text-muted-foreground/30 h-6 w-6" />
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.advanced.replay.noRequests')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -143,7 +151,7 @@ export const ReplayTab: React.FC = () => {
|
||||
<span className="text-[13px] font-medium">
|
||||
{request.method} {request.endpoint}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{formatTimestamp(request.timestamp)}
|
||||
{request.duration && ` · ${request.duration}ms`}
|
||||
</span>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
@@ -6,17 +6,42 @@ import { ipc } from '@/ipc/manager';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
Copy,
|
||||
CheckCircle,
|
||||
ShieldAlert,
|
||||
XCircle,
|
||||
Terminal,
|
||||
Code2,
|
||||
Braces,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
|
||||
const IDE_CONFIGS = [
|
||||
{ key: 'opencode', label: 'OpenCode', icon: Sparkles },
|
||||
{ key: 'vscode', label: 'VS Code', icon: Code2 },
|
||||
{ key: 'cursor', label: 'Cursor', icon: Terminal },
|
||||
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
|
||||
] as const;
|
||||
|
||||
export const ToolsTab: React.FC = () => {
|
||||
interface ToolsTabProps {
|
||||
availableModelIds?: string[];
|
||||
}
|
||||
|
||||
export const ToolsTab: React.FC<ToolsTabProps> = ({
|
||||
availableModelIds = ['gemini-3.1-pro-high'],
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [selectedModel, setSelectedModel] = useState<string>(availableModelIds[0]);
|
||||
|
||||
const { data: parityData, isLoading: parityLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'parityCounters'],
|
||||
@@ -25,16 +50,11 @@ export const ToolsTab: React.FC = () => {
|
||||
});
|
||||
|
||||
const ideQueries = useQueries({
|
||||
queries: [
|
||||
{
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
|
||||
},
|
||||
{
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
|
||||
},
|
||||
],
|
||||
queries: IDE_CONFIGS.map(({ key }) => ({
|
||||
queryKey: ['proxyAdvanced', 'ideConfig', key, selectedModel],
|
||||
queryFn: () =>
|
||||
ipc.client.proxyAdvanced.generateIdeConfig({ ide: key, defaultModel: selectedModel }),
|
||||
})),
|
||||
});
|
||||
|
||||
const parity = parityData?.success ? parityData.data : undefined;
|
||||
@@ -44,15 +64,21 @@ export const ToolsTab: React.FC = () => {
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
};
|
||||
|
||||
const getIdeInstructions = (key: string): string => {
|
||||
const getIdeInstructions = (key: string, index: number): string => {
|
||||
if (key === 'claude-code') {
|
||||
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
|
||||
}
|
||||
const index = key === 'vscode' ? 0 : 1;
|
||||
const result = ideQueries[index].data;
|
||||
const result = ideQueries[index]?.data;
|
||||
if (result?.success && result.data) {
|
||||
const d = result.data;
|
||||
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = result.data as any;
|
||||
if (d.format === 'json') {
|
||||
return JSON.stringify(d.content, null, 2);
|
||||
}
|
||||
if (d.format === 'env' || d.format === 'shell') {
|
||||
return typeof d.content === 'string' ? d.content : JSON.stringify(d.content, null, 2);
|
||||
}
|
||||
return `${d.instructions || ''}\n${d.proxySetting || 'URL'}: http://localhost:8045\n${d.apiKeySetting || 'Key'}: YOUR_API_KEY`;
|
||||
}
|
||||
return '';
|
||||
};
|
||||
@@ -87,24 +113,45 @@ export const ToolsTab: React.FC = () => {
|
||||
</div>
|
||||
|
||||
{/* IDE Quick Setup */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
|
||||
<p className="text-xs text-muted-foreground">Quick configuration for popular IDEs</p>
|
||||
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
|
||||
<div className="space-y-1">
|
||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
|
||||
<p className="text-muted-foreground text-xs">Quick configuration for popular IDEs</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Default Model:</span>
|
||||
<Select value={selectedModel} onValueChange={setSelectedModel}>
|
||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModelIds.map((id) => (
|
||||
<SelectItem key={id} value={id}>
|
||||
{id}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-3">
|
||||
<div className="space-y-3 p-5">
|
||||
{isIdeLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
|
||||
const instructions = getIdeInstructions(key);
|
||||
IDE_CONFIGS.map(({ key, label, icon: Icon }, index) => {
|
||||
const instructions = getIdeInstructions(key, index);
|
||||
const resultData = ideQueries[index]?.data;
|
||||
const hasError = resultData && !resultData.success;
|
||||
const errorMsg = resultData?.error || '';
|
||||
|
||||
return (
|
||||
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="flex items-center justify-between p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||
<Icon className="text-muted-foreground h-4 w-4" />
|
||||
<span className="text-[13px] font-medium">{label}</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -112,13 +159,19 @@ export const ToolsTab: React.FC = () => {
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => copyToClipboard(instructions)}
|
||||
disabled={!instructions || hasError}
|
||||
>
|
||||
<Copy className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t('proxy.advanced.tools.copyConfig')}
|
||||
</Button>
|
||||
</div>
|
||||
{instructions && (
|
||||
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||
{hasError && (
|
||||
<div className="bg-card border-t border-white/[0.06] p-3 font-mono text-xs text-amber-500/80">
|
||||
{errorMsg}
|
||||
</div>
|
||||
)}
|
||||
{instructions && !hasError && (
|
||||
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{instructions}
|
||||
</pre>
|
||||
)}
|
||||
@@ -128,7 +181,6 @@ export const ToolsTab: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,190 +0,0 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
interface TrafficLogEntry {
|
||||
timestamp: string;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
endpoint: string;
|
||||
method?: string;
|
||||
status?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
interface TrafficLogTableProps {
|
||||
logs: TrafficLogEntry[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
|
||||
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="ml-1 inline h-3 w-3" />
|
||||
) : (
|
||||
<ArrowDown className="ml-1 inline h-3 w-3" />
|
||||
);
|
||||
}
|
||||
|
||||
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
|
||||
const { t } = useTranslation();
|
||||
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc');
|
||||
|
||||
const toggleSort = (key: SortKey) => {
|
||||
if (sortKey === key) {
|
||||
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
|
||||
} else {
|
||||
setSortKey(key);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedLogs = useMemo(() => {
|
||||
const dir = sortDir === 'asc' ? 1 : -1;
|
||||
return [...logs].sort((a, b) => {
|
||||
switch (sortKey) {
|
||||
case 'timestamp':
|
||||
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
|
||||
case 'direction':
|
||||
return (a.direction || '').localeCompare(b.direction || '') * dir;
|
||||
case 'endpoint':
|
||||
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
|
||||
case 'method':
|
||||
return (a.method || '').localeCompare(b.method || '') * dir;
|
||||
case 'status': {
|
||||
const aStatus = a.status ?? -1;
|
||||
const bStatus = b.status ?? -1;
|
||||
return (aStatus - bStatus) * dir;
|
||||
}
|
||||
case 'duration': {
|
||||
const aDur = a.durationMs ?? -1;
|
||||
const bDur = b.durationMs ?? -1;
|
||||
return (aDur - bDur) * dir;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [logs, sortKey, sortDir]);
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">
|
||||
{t('proxy.advanced.trafficLog.title')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{isLoading ? (
|
||||
<div className="flex h-32 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : logs.length === 0 ? (
|
||||
<div className="py-8 text-center text-[13px] text-muted-foreground">
|
||||
{t('proxy.advanced.trafficLog.noLogs')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('timestamp')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.timestamp')}
|
||||
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('direction')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.direction')}
|
||||
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('endpoint')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.endpoint')}
|
||||
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('method')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.method')}
|
||||
<SortIcon column="method" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||
onClick={() => toggleSort('status')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.status')}
|
||||
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
<th
|
||||
className="cursor-pointer pb-2 font-medium select-none"
|
||||
onClick={() => toggleSort('duration')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.duration')}
|
||||
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedLogs.map((log, i) => (
|
||||
<tr
|
||||
key={`${log.requestId}-${i}`}
|
||||
className="border-b border-white/[0.04] last:border-0"
|
||||
>
|
||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</td>
|
||||
<td className="py-2 pr-4">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
log.direction === 'inbound'
|
||||
? 'border-blue-500/30 text-blue-400'
|
||||
: log.direction === 'outbound'
|
||||
? 'border-emerald-500/30 text-emerald-400'
|
||||
: 'border-slate-500/30 text-slate-400'
|
||||
}
|
||||
>
|
||||
{log.direction}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
|
||||
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
|
||||
<td className="py-2 pr-4 text-xs">
|
||||
{log.status != null ? (
|
||||
<Badge
|
||||
variant={log.status >= 400 ? 'destructive' : 'secondary'}
|
||||
className="text-xs"
|
||||
>
|
||||
{log.status}
|
||||
</Badge>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td className="py-2 text-xs">
|
||||
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||
destructive:
|
||||
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
|
||||
outline:
|
||||
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
|
||||
secondary:
|
||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||
ghost:
|
||||
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
||||
ghost: 'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
||||
link: 'text-accent underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
|
||||
@@ -26,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
||||
({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('text-sm font-semibold leading-none tracking-tight', className)}
|
||||
className={cn('text-sm leading-none font-semibold tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
|
||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
||||
<CheckboxPrimitive.Root
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'peer h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background',
|
||||
'peer ring-offset-background focus-visible:ring-accent/30 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||
'gap-4 rounded-xl border border-white/[0.08] bg-card p-6 shadow-2xl shadow-black/30',
|
||||
'bg-card gap-4 rounded-xl border border-white/[0.08] p-6 shadow-2xl shadow-black/30',
|
||||
'duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/30 disabled:pointer-events-none">
|
||||
<DialogPrimitive.Close className="focus:ring-ring/30 absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
|
||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] outline-none select-none transition-colors focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
||||
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:text-foreground relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -154,7 +154,11 @@ const DropdownMenuLabel = React.forwardRef<
|
||||
>(({ className, inset, ...props }, ref) => (
|
||||
<DropdownMenuPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||
className={cn(
|
||||
'text-muted-foreground px-2 py-1.5 text-xs font-medium',
|
||||
inset && 'pl-8',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
|
||||
@@ -8,10 +8,10 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-1 text-[13px] text-foreground shadow-none transition-colors duration-200',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||
'bg-muted/50 text-foreground flex h-9 w-full rounded-lg border border-white/[0.08] px-3 py-1 text-[13px] shadow-none transition-colors duration-200',
|
||||
'file:text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
|
||||
'focus-visible:border-accent/50 focus-visible:ring-accent/20 focus-visible:ring-1 focus-visible:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -19,9 +19,9 @@ const SelectTrigger = React.forwardRef<
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-2 text-[13px] text-foreground transition-colors duration-200',
|
||||
'bg-muted/50 text-foreground flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] px-3 py-2 text-[13px] transition-colors duration-200',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
|
||||
'focus:border-accent/50 focus:ring-accent/20 focus:ring-1 focus:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className,
|
||||
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] bg-popover text-popover-foreground shadow-xl shadow-black/20',
|
||||
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] shadow-xl shadow-black/20',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
position === 'popper' &&
|
||||
@@ -104,7 +104,7 @@ const SelectLabel = React.forwardRef<
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SelectPrimitive.Label
|
||||
ref={ref}
|
||||
className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
|
||||
className={cn('text-muted-foreground py-1.5 pr-2 pl-8 text-xs font-medium', className)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
@@ -117,8 +117,8 @@ const SelectItem = React.forwardRef<
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] outline-none select-none transition-colors',
|
||||
'focus:bg-white/[0.06] focus:text-foreground',
|
||||
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none',
|
||||
'focus:text-foreground focus:bg-white/[0.06]',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-3.5 w-3.5 text-accent" />
|
||||
<Check className="text-accent h-3.5 w-3.5" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
return <div className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)} {...props} />;
|
||||
}
|
||||
|
||||
export { Skeleton };
|
||||
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
|
||||
<SwitchPrimitives.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'focus-visible:ring-ring/30 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
|
||||
className,
|
||||
|
||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
|
||||
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg border border-white/[0.04] p-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -28,8 +28,8 @@ const TabsTrigger = React.forwardRef<
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center rounded-md px-3 py-1 text-[13px] font-medium whitespace-nowrap transition-all duration-200',
|
||||
'text-muted-foreground hover:text-foreground',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-white/[0.08]',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-white/[0.08] data-[state=active]:shadow-sm',
|
||||
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
@@ -45,7 +45,7 @@ const TabsContent = React.forwardRef<
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||
'animate-in focus-visible:ring-ring/30 mt-3 focus-visible:ring-2 focus-visible:outline-none',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-lg border border-white/[0.08] bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg shadow-black/20',
|
||||
'bg-popover text-popover-foreground z-50 overflow-hidden rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs shadow-lg shadow-black/20',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
|
||||
@@ -29,10 +29,10 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
children,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="bg-card flex flex-col rounded-xl border border-white/[0.06]">
|
||||
<div className="flex flex-col p-5">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
|
||||
<h3 className="text-foreground text-[13px] font-semibold tracking-tight">{title}</h3>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{isLoading ? (
|
||||
@@ -41,8 +41,8 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
||||
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-lg">
|
||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
@@ -53,9 +53,9 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
||||
) : isEmpty ? (
|
||||
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
|
||||
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
|
||||
<BarChart3 className="text-muted-foreground/60 h-5 w-5" />
|
||||
</div>
|
||||
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
|
||||
<p className="text-foreground text-[13px] font-medium">{emptyTitle}</p>
|
||||
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||
{emptyDescription}
|
||||
</p>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+204
-206
@@ -1,206 +1,204 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { TrafficLogTable } from './TrafficLogTable';
|
||||
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
|
||||
|
||||
export const MonitorTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [direction, setDirection] = useState<string>('all');
|
||||
|
||||
const { data: metricsData, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'metrics'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: cbData, isLoading: cbLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'circuitBreaker'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'logs', direction],
|
||||
queryFn: () =>
|
||||
ipc.client.proxyAdvanced.getRecentTrafficLogs({
|
||||
limit: 50,
|
||||
direction: direction === 'all' ? undefined : direction,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: statsData } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'stats'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const metrics = metricsData?.success ? metricsData.data : undefined;
|
||||
const circuitBreakers = cbData?.success ? cbData.data : undefined;
|
||||
const logs = logsData?.success ? logsData.data : [];
|
||||
const stats = statsData?.success ? statsData.data : undefined;
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
|
||||
if (result.success && result.data) {
|
||||
navigator.clipboard.writeText(result.data);
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
} else {
|
||||
toast({ title: t('common.error'), description: result.error });
|
||||
}
|
||||
};
|
||||
|
||||
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
|
||||
if (state === 'closed') return 'default';
|
||||
if (state === 'open') return 'destructive';
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.requestsPerMinute')}
|
||||
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
|
||||
icon={Zap}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.avgLatency')}
|
||||
value={metrics ? Math.round(metrics.avgLatency) : 0}
|
||||
icon={Clock}
|
||||
isLoading={metricsLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.errorRate')}
|
||||
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
|
||||
icon={AlertTriangle}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.activeConnections')}
|
||||
value={metrics?.activeConnections ?? 0}
|
||||
icon={Network}
|
||||
isLoading={metricsLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.cacheStatus')}
|
||||
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
|
||||
icon={HardDrive}
|
||||
isLoading={metricsLoading}
|
||||
accent="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Circuit Breakers */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">
|
||||
{t('proxy.advanced.circuitBreaker.title')}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{cbLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !circuitBreakers ? (
|
||||
<p className="text-[13px] text-muted-foreground">No data</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
||||
<div key={api} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium">{api}</span>
|
||||
<Badge variant={getCircuitBadgeVariant(state.state)}>
|
||||
{state.state}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
|
||||
</div>
|
||||
{state.lastFailure && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
|
||||
{new Date(state.lastFailure).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Logs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[13px] font-semibold">
|
||||
{t('proxy.advanced.trafficLog.title')}
|
||||
</h3>
|
||||
{stats && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Total: {stats.total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={direction} onValueChange={setDirection}>
|
||||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||||
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">
|
||||
{t('proxy.advanced.trafficLog.all')}
|
||||
</SelectItem>
|
||||
<SelectItem value="inbound">
|
||||
{t('proxy.advanced.trafficLog.inbound')}
|
||||
</SelectItem>
|
||||
<SelectItem value="outbound">
|
||||
{t('proxy.advanced.trafficLog.outbound')}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('json')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportJson')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportCsv')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { TrafficLogTable } from './TrafficLogTable';
|
||||
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
|
||||
|
||||
export const MonitorTab: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [direction, setDirection] = useState<string>('all');
|
||||
|
||||
const { data: metricsData, isLoading: metricsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'metrics'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: cbData, isLoading: cbLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'circuitBreaker'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: logsData, isLoading: logsLoading } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'logs', direction],
|
||||
queryFn: () =>
|
||||
ipc.client.proxyAdvanced.getRecentTrafficLogs({
|
||||
limit: 50,
|
||||
direction: direction === 'all' ? undefined : direction,
|
||||
}),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const { data: statsData } = useQuery({
|
||||
queryKey: ['proxyAdvanced', 'stats'],
|
||||
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
|
||||
refetchInterval: 30000,
|
||||
});
|
||||
|
||||
const metrics = metricsData?.success ? metricsData.data : undefined;
|
||||
const circuitBreakers = cbData?.success ? cbData.data : undefined;
|
||||
const logs = logsData?.success
|
||||
? (logsData.data || []).map((log) => ({
|
||||
...log,
|
||||
model: log.metadata?.model as string | undefined,
|
||||
tokensPrompt: log.metadata?.tokensPrompt as number | undefined,
|
||||
tokensCompletion: log.metadata?.tokensCompletion as number | undefined,
|
||||
tokensTotal:
|
||||
(((log.metadata?.tokensPrompt as number) || 0) +
|
||||
((log.metadata?.tokensCompletion as number) || 0)) ||
|
||||
undefined,
|
||||
}))
|
||||
: [];
|
||||
const stats = statsData?.success ? statsData.data : undefined;
|
||||
|
||||
const handleExport = async (format: 'json' | 'csv') => {
|
||||
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
|
||||
if (result.success && result.data) {
|
||||
navigator.clipboard.writeText(result.data);
|
||||
toast({ title: t('proxy.advanced.tools.copyConfig') });
|
||||
} else {
|
||||
toast({ title: t('common.error'), description: result.error });
|
||||
}
|
||||
};
|
||||
|
||||
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
|
||||
if (state === 'closed') return 'default';
|
||||
if (state === 'open') return 'destructive';
|
||||
return 'secondary';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{/* Metrics */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.requestsPerMinute')}
|
||||
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
|
||||
icon={Zap}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.avgLatency')}
|
||||
value={metrics ? Math.round(metrics.avgLatency) : 0}
|
||||
icon={Clock}
|
||||
isLoading={metricsLoading}
|
||||
accent="blue"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.errorRate')}
|
||||
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
|
||||
icon={AlertTriangle}
|
||||
isLoading={metricsLoading}
|
||||
accent="amber"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.activeConnections')}
|
||||
value={metrics?.activeConnections ?? 0}
|
||||
icon={Network}
|
||||
isLoading={metricsLoading}
|
||||
accent="green"
|
||||
/>
|
||||
<StatCard
|
||||
label={t('proxy.advanced.metrics.cacheStatus')}
|
||||
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
|
||||
icon={HardDrive}
|
||||
isLoading={metricsLoading}
|
||||
accent="purple"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Circuit Breakers */}
|
||||
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.circuitBreaker.title')}</h4>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{cbLoading ? (
|
||||
<div className="flex h-20 items-center justify-center">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
</div>
|
||||
) : !circuitBreakers ? (
|
||||
<p className="text-muted-foreground text-[13px]">No data</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
||||
<div
|
||||
key={api}
|
||||
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[13px] font-medium">{api}</span>
|
||||
<Badge variant={getCircuitBadgeVariant(state.state)}>{state.state}</Badge>
|
||||
</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">
|
||||
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
|
||||
</div>
|
||||
{state.lastFailure && (
|
||||
<div className="text-muted-foreground text-xs">
|
||||
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
|
||||
{new Date(state.lastFailure).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Traffic Logs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h3 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h3>
|
||||
{stats && <span className="text-muted-foreground text-xs">Total: {stats.total}</span>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select value={direction} onValueChange={setDirection}>
|
||||
<SelectTrigger className="h-8 w-[140px] text-xs">
|
||||
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">{t('proxy.advanced.trafficLog.all')}</SelectItem>
|
||||
<SelectItem value="inbound">{t('proxy.advanced.trafficLog.inbound')}</SelectItem>
|
||||
<SelectItem value="outbound">{t('proxy.advanced.trafficLog.outbound')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('json')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportJson')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => handleExport('csv')}
|
||||
>
|
||||
{t('proxy.advanced.trafficLog.exportCsv')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +1,18 @@
|
||||
/** Premium chart palette — refined for dark mode contrast */
|
||||
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
||||
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
||||
export const COLOR_COMPLETION = '#34d399'; // emerald-400
|
||||
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
||||
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
||||
|
||||
export const PIE_COLORS = [
|
||||
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
|
||||
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
|
||||
'#60a5fa',
|
||||
'#34d399',
|
||||
'#fbbf24',
|
||||
'#a78bfa',
|
||||
'#f472b6',
|
||||
'#38bdf8',
|
||||
'#4ade80',
|
||||
'#facc15',
|
||||
'#c084fc',
|
||||
];
|
||||
|
||||
export const MODEL_COLORS: Record<string, string> = {
|
||||
@@ -31,10 +38,7 @@ export function BarTooltip({ indexValue, data }: any) {
|
||||
<div className="flex flex-col gap-1">
|
||||
{keys.map((key: string) => (
|
||||
<div key={key} className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: colorMap[key] }}
|
||||
/>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: colorMap[key] }} />
|
||||
<span className="text-muted-foreground">{key}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
|
||||
@@ -50,10 +54,7 @@ export function PieTooltip({ datum }: any) {
|
||||
return (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: datum.color }}
|
||||
/>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: datum.color }} />
|
||||
<span className="text-muted-foreground">{datum.id}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
|
||||
@@ -66,12 +67,11 @@ export function PieTooltip({ datum }: any) {
|
||||
export function LineTooltip({ point }: any) {
|
||||
return (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
|
||||
<p className="text-foreground mb-1 text-xs font-medium">
|
||||
{point.data.xFormatted ?? point.data.x}
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: point.serieColor }}
|
||||
/>
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: point.serieColor }} />
|
||||
<span className="text-muted-foreground">{point.serieId}:</span>
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
||||
|
||||
@@ -11,7 +11,7 @@ interface TrendInfo {
|
||||
}
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
label: React.ReactNode;
|
||||
value: number | string;
|
||||
icon: LucideIcon;
|
||||
isLoading?: boolean;
|
||||
@@ -82,10 +82,7 @@ function Sparkline({ data, accent }: { data: number[]; accent: string }) {
|
||||
</linearGradient>
|
||||
</defs>
|
||||
{/* Area fill */}
|
||||
<polygon
|
||||
points={`0,${h} ${points.join(' ')} ${w},${h}`}
|
||||
fill={`url(#sparkGrad-${accent})`}
|
||||
/>
|
||||
<polygon points={`0,${h} ${points.join(' ')} ${w},${h}`} fill={`url(#sparkGrad-${accent})`} />
|
||||
{/* Line */}
|
||||
<polyline
|
||||
points={points.join(' ')}
|
||||
@@ -103,11 +100,7 @@ function TrendBadge({ trend }: { trend: TrendInfo }) {
|
||||
const isUp = trend.direction === 'up';
|
||||
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
||||
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
||||
const color = isNeutral
|
||||
? 'text-muted-foreground'
|
||||
: isUp
|
||||
? 'text-emerald-400'
|
||||
: 'text-rose-400';
|
||||
const color = isNeutral ? 'text-muted-foreground' : isUp ? 'text-emerald-400' : 'text-rose-400';
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
||||
@@ -130,7 +123,7 @@ export const StatCard: React.FC<StatCardProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
|
||||
'group bg-card relative rounded-xl border border-white/[0.06] p-4 transition-all duration-200',
|
||||
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -685,7 +685,7 @@ export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
|
||||
// Trigger an immediate check if enabled
|
||||
if (enabled) {
|
||||
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
|
||||
CloudMonitorService.poll().catch((err: unknown) =>
|
||||
CloudMonitorService.poll().catch((err: unknown) =>
|
||||
logger.error('Failed to poll after enabling auto-switch', err),
|
||||
);
|
||||
}
|
||||
|
||||
+20
-4
@@ -193,7 +193,11 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
await CloudAccountRepo.setAccountProxy(input.accountId, input.proxyUrl);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] setAccountProxy error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] setAccountProxy error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -204,7 +208,11 @@ export const cloudRouter = os.router({
|
||||
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] syncLocalAccount error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] syncLocalAccount error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw toSyncLocalAccountORPCError(error);
|
||||
}
|
||||
}),
|
||||
@@ -274,7 +282,11 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
return await exportCloudAccounts(input.stripTokens);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] exportCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] exportCloudAccounts error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
@@ -298,7 +310,11 @@ export const cloudRouter = os.router({
|
||||
try {
|
||||
return await importCloudAccounts(input.jsonContent, input.strategy);
|
||||
} catch (error: unknown) {
|
||||
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
|
||||
logger.error(
|
||||
'[ORPC] importCloudAccounts error:',
|
||||
extractErrorMessage(error),
|
||||
error instanceof Error ? error.stack : undefined,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -89,12 +89,8 @@ export function ensureProxyMetricsTable(rawDb: Database.Database): void {
|
||||
metadata_json TEXT
|
||||
);
|
||||
`);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
|
||||
);
|
||||
rawDb.exec(
|
||||
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
|
||||
);
|
||||
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`);
|
||||
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`);
|
||||
} catch (error) {
|
||||
logger.error('Failed to ensure proxy_request_metrics table', error);
|
||||
throw error;
|
||||
@@ -201,17 +197,30 @@ export class ProxyMetricsRepo {
|
||||
const reqRow = raw
|
||||
.prepare(
|
||||
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
|
||||
MIN(timestamp) as minTimestamp, MAX(timestamp) as maxTimestamp,
|
||||
CASE WHEN COUNT(*) = 0 THEN 0
|
||||
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
|
||||
END as errorRate
|
||||
FROM proxy_request_metrics WHERE timestamp >= ?`,
|
||||
)
|
||||
.get(cutoff) as
|
||||
| { total: number; avgLatency: number | null; errorRate: number }
|
||||
| {
|
||||
total: number;
|
||||
avgLatency: number | null;
|
||||
errorRate: number;
|
||||
minTimestamp: number | null;
|
||||
maxTimestamp: number | null;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const totalRequests = reqRow?.total ?? 0;
|
||||
const minutes = Math.max(windowMs / 60_000, 1);
|
||||
let minutes = Math.max(windowMs / 60_000, 1);
|
||||
|
||||
if (reqRow?.minTimestamp && reqRow?.maxTimestamp && totalRequests > 0) {
|
||||
const spanMs = reqRow.maxTimestamp - reqRow.minTimestamp;
|
||||
minutes = Math.max(spanMs / 60_000, 1);
|
||||
}
|
||||
|
||||
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
|
||||
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
|
||||
const errorRatePercent = reqRow?.errorRate ?? 0;
|
||||
@@ -244,7 +253,9 @@ export class ProxyMetricsRepo {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
|
||||
raw
|
||||
.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`)
|
||||
.run(cutoffTimestamp);
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete old proxy metrics records', error);
|
||||
@@ -298,7 +309,10 @@ export class TrafficLogsRepo {
|
||||
}
|
||||
}
|
||||
|
||||
static getRecent(limit: number = 50, direction?: string): Array<{
|
||||
static getRecent(
|
||||
limit: number = 50,
|
||||
direction?: string,
|
||||
): Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
requestId: string;
|
||||
@@ -349,10 +363,13 @@ export class TrafficLogsRepo {
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
error: r.error_message
|
||||
? {
|
||||
message: r.error_message,
|
||||
stack: r.error_stack ?? undefined,
|
||||
status: r.error_status ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -361,10 +378,18 @@ export class TrafficLogsRepo {
|
||||
}
|
||||
}
|
||||
|
||||
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
|
||||
static getStats(): {
|
||||
byDirection: Record<string, number>;
|
||||
byEndpoint: Record<string, number>;
|
||||
total: number;
|
||||
} {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
|
||||
const rows = raw
|
||||
.prepare(
|
||||
`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`,
|
||||
)
|
||||
.all() as Array<{
|
||||
direction: string;
|
||||
endpoint: string;
|
||||
cnt: number;
|
||||
@@ -400,7 +425,9 @@ export class TrafficLogsRepo {
|
||||
}> {
|
||||
const { raw } = getProxyMetricsDb();
|
||||
try {
|
||||
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
|
||||
const rows = raw
|
||||
.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`)
|
||||
.all() as Array<{
|
||||
timestamp: number;
|
||||
direction: string;
|
||||
request_id: string;
|
||||
@@ -427,10 +454,13 @@ export class TrafficLogsRepo {
|
||||
body: r.body_json ? JSON.parse(r.body_json) : undefined,
|
||||
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
|
||||
durationMs: r.duration_ms ?? undefined,
|
||||
error:
|
||||
r.error_message
|
||||
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
|
||||
: undefined,
|
||||
error: r.error_message
|
||||
? {
|
||||
message: r.error_message,
|
||||
stack: r.error_stack ?? undefined,
|
||||
status: r.error_status ?? undefined,
|
||||
}
|
||||
: undefined,
|
||||
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import {
|
||||
bootstrapNestServer,
|
||||
isNestServerRunning,
|
||||
stopNestServer,
|
||||
} from '../../server/main';
|
||||
import { bootstrapNestServer, isNestServerRunning, stopNestServer } from '../../server/main';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
|
||||
/**
|
||||
@@ -25,8 +21,6 @@ export async function closeGeminiNexus(): Promise<void> {
|
||||
await stopNestServer();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* Waits for the Gemini Nexus process to exit.
|
||||
* Polls the NestJS server status until it stops or the timeout is reached.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { logger } from '../../utils/logger';
|
||||
import { getServiceOrThrow } from './service-registry';
|
||||
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { getServiceOrThrow, getServiceOptional } from './service-registry';
|
||||
import type {
|
||||
IdeConfigResult,
|
||||
SupportedIde,
|
||||
} from '../../server/modules/proxy/proxy-ide-config.service';
|
||||
import { TrafficLogsRepo } from '../database/proxyMetricsHandler';
|
||||
import { CloudAccountRepo } from '../database/cloudHandler';
|
||||
import { ConfigManager } from '../config/manager';
|
||||
import { networkInterfaces } from 'os';
|
||||
|
||||
export interface TrafficLogEntryParsed {
|
||||
timestamp: string;
|
||||
@@ -84,7 +89,15 @@ export async function exportTrafficLogs(
|
||||
}
|
||||
|
||||
// CSV format
|
||||
const headers = ['timestamp', 'direction', 'requestId', 'endpoint', 'method', 'status', 'durationMs'];
|
||||
const headers = [
|
||||
'timestamp',
|
||||
'direction',
|
||||
'requestId',
|
||||
'endpoint',
|
||||
'method',
|
||||
'status',
|
||||
'durationMs',
|
||||
];
|
||||
const rowsCsv = rows.map((e) =>
|
||||
[
|
||||
new Date(e.timestamp).toISOString(),
|
||||
@@ -108,11 +121,27 @@ export async function exportTrafficLogs(
|
||||
|
||||
export async function getParityCounters(): Promise<{
|
||||
success: boolean;
|
||||
data?: { totalRequests: number; matchedRequests: number; parityViolations: number; lastUpdated: string };
|
||||
data?: {
|
||||
totalRequests: number;
|
||||
matchedRequests: number;
|
||||
parityViolations: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
const tokenManager = getServiceOptional('tokenManager');
|
||||
if (!tokenManager) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
totalRequests: 0,
|
||||
matchedRequests: 0,
|
||||
parityViolations: 0,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: tokenManager.getParityCounters(),
|
||||
@@ -124,14 +153,24 @@ export async function getParityCounters(): Promise<{
|
||||
|
||||
export async function getCircuitBreakerStatus(): Promise<{
|
||||
success: boolean;
|
||||
data?: { states: Record<string, { state: string; failures: number; lastFailure: string | null }>; globalEnabled: boolean };
|
||||
data?: {
|
||||
states: Record<string, { state: string; failures: number; lastFailure: string | null }>;
|
||||
globalEnabled: boolean;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const tokenManager = getServiceOrThrow('tokenManager');
|
||||
const tokenManager = getServiceOptional('tokenManager');
|
||||
if (!tokenManager) {
|
||||
return {
|
||||
success: true,
|
||||
data: { states: {}, globalEnabled: true },
|
||||
};
|
||||
}
|
||||
|
||||
const rawStatus = tokenManager.getCircuitBreakerStatus();
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> = {};
|
||||
const states: Record<string, { state: string; failures: number; lastFailure: string | null }> =
|
||||
{};
|
||||
for (const [accountId, entry] of Object.entries(rawStatus)) {
|
||||
states[accountId] = {
|
||||
state: entry.state,
|
||||
@@ -154,7 +193,7 @@ export async function getCircuitBreakerStatus(): Promise<{
|
||||
|
||||
export async function getProxyMetrics(): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
data?: {
|
||||
uptimeSeconds: number;
|
||||
totalRequests: number;
|
||||
activeConnections: number;
|
||||
@@ -166,7 +205,21 @@ export async function getProxyMetrics(): Promise<{
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyMetrics = getServiceOrThrow('proxyMetrics');
|
||||
const proxyMetrics = getServiceOptional('proxyMetrics');
|
||||
if (!proxyMetrics) {
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
uptimeSeconds: 0,
|
||||
totalRequests: 0,
|
||||
activeConnections: 0,
|
||||
avgLatency: 0,
|
||||
errorRate: 0,
|
||||
requestsPerMinute: 0,
|
||||
cacheHitRate: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const metrics = proxyMetrics.getMetrics();
|
||||
return {
|
||||
@@ -187,9 +240,14 @@ export async function getProxyMetrics(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
export async function getRecentRequests(): Promise<{ success: boolean; data?: unknown[]; error?: string }> {
|
||||
export async function getRecentRequests(): Promise<{
|
||||
success: boolean;
|
||||
data?: unknown[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
const proxyReplay = getServiceOptional('proxyReplay');
|
||||
if (!proxyReplay) return { success: true, data: [] };
|
||||
return {
|
||||
success: true,
|
||||
data: proxyReplay.getRecentRequests(20),
|
||||
@@ -201,7 +259,11 @@ export async function getRecentRequests(): Promise<{ success: boolean; data?: un
|
||||
|
||||
export async function replayRequest(
|
||||
requestId: string,
|
||||
): Promise<{ success: boolean; data?: { original: unknown; newResponse: unknown }; error?: string }> {
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: { original: unknown; newResponse: unknown };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyReplay = getServiceOrThrow('proxyReplay');
|
||||
const result = await proxyReplay.replayRequest(requestId);
|
||||
@@ -278,14 +340,14 @@ export async function getModelCapabilities(): Promise<{
|
||||
}
|
||||
}
|
||||
|
||||
function mapIdeConfigResult(
|
||||
result: IdeConfigResult,
|
||||
): {
|
||||
function mapIdeConfigResult(result: IdeConfigResult): {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
proxySetting?: string;
|
||||
apiKeySetting?: string;
|
||||
instructions?: string;
|
||||
format: 'json' | 'shell' | 'env' | 'instructions';
|
||||
content: Record<string, unknown> | string;
|
||||
} {
|
||||
switch (result.ide) {
|
||||
case 'vscode':
|
||||
@@ -295,6 +357,8 @@ function mapIdeConfigResult(
|
||||
proxySetting: 'openai.url',
|
||||
apiKeySetting: 'openai.apiKey',
|
||||
instructions: 'Add these settings to your VS Code settings.json',
|
||||
format: result.format,
|
||||
content: result.content as Record<string, unknown>,
|
||||
};
|
||||
case 'cursor':
|
||||
return {
|
||||
@@ -303,6 +367,8 @@ function mapIdeConfigResult(
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Set these environment variables for Cursor',
|
||||
format: result.format,
|
||||
content: result.content as Record<string, unknown>,
|
||||
};
|
||||
case 'claude-code':
|
||||
return {
|
||||
@@ -311,6 +377,8 @@ function mapIdeConfigResult(
|
||||
proxySetting: 'ANTHROPIC_BASE_URL',
|
||||
apiKeySetting: 'ANTHROPIC_API_KEY',
|
||||
instructions: 'Run these export commands before starting Claude Code',
|
||||
format: result.format,
|
||||
content: result.content as string,
|
||||
};
|
||||
case 'jetbrains':
|
||||
return {
|
||||
@@ -319,6 +387,8 @@ function mapIdeConfigResult(
|
||||
proxySetting: 'Base URL',
|
||||
apiKeySetting: 'API Key',
|
||||
instructions: String(result.content),
|
||||
format: result.format,
|
||||
content: result.content as string,
|
||||
};
|
||||
case 'opencide':
|
||||
return {
|
||||
@@ -327,9 +397,19 @@ function mapIdeConfigResult(
|
||||
proxySetting: 'OPENAI_BASE_URL',
|
||||
apiKeySetting: 'OPENAI_API_KEY',
|
||||
instructions: 'Add these lines to your .env file',
|
||||
format: result.format,
|
||||
content: result.content as string,
|
||||
};
|
||||
case 'opencode':
|
||||
return {
|
||||
name: 'OpenCode',
|
||||
settingsPath: '.config/opencode/providers.json',
|
||||
instructions: 'Add this snippet to your providers.json',
|
||||
format: result.format,
|
||||
content: result.content as Record<string, unknown>,
|
||||
};
|
||||
default: {
|
||||
const exhaustiveCheck: never = result.ide;
|
||||
const exhaustiveCheck: never = result.ide as never;
|
||||
throw new Error(`Unsupported IDE: ${String(exhaustiveCheck)}`);
|
||||
}
|
||||
}
|
||||
@@ -337,29 +417,121 @@ function mapIdeConfigResult(
|
||||
|
||||
export async function generateIdeConfig(
|
||||
ide: string,
|
||||
defaultModel?: string,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
proxySetting: string;
|
||||
apiKeySetting: string;
|
||||
instructions: string;
|
||||
proxySetting?: string;
|
||||
apiKeySetting?: string;
|
||||
instructions?: string;
|
||||
format: 'json' | 'shell' | 'env' | 'instructions';
|
||||
content: Record<string, unknown> | string;
|
||||
};
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const proxyIdeConfig = getServiceOrThrow('proxyIdeConfig');
|
||||
|
||||
const supportedIdes = ['cursor', 'vscode', 'claude-code', 'jetbrains', 'opencide'] as const;
|
||||
const normalizedIde = ide.toLowerCase() as typeof supportedIdes[number];
|
||||
const supportedIdes = [
|
||||
'cursor',
|
||||
'vscode',
|
||||
'claude-code',
|
||||
'jetbrains',
|
||||
'opencide',
|
||||
'opencode',
|
||||
] as const;
|
||||
const normalizedIde = ide.toLowerCase() as (typeof supportedIdes)[number];
|
||||
if (!supportedIdes.includes(normalizedIde)) {
|
||||
return { success: false, error: `Unsupported IDE: ${ide}` };
|
||||
}
|
||||
|
||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde);
|
||||
if (!result) {
|
||||
return { success: false, error: 'Failed to generate IDE config: server not configured' };
|
||||
// Attempt to use NestJS service if running, fallback to manual generation
|
||||
const proxyIdeConfig = getServiceOptional('proxyIdeConfig');
|
||||
if (proxyIdeConfig) {
|
||||
const result = proxyIdeConfig.generateConfigForCurrentServer(normalizedIde, defaultModel);
|
||||
if (result) return { success: true, data: mapIdeConfigResult(result) };
|
||||
}
|
||||
|
||||
// Fallback logic when proxy is stopped
|
||||
const config = ConfigManager.getCachedConfig()?.proxy;
|
||||
const port = config?.port || 8045;
|
||||
const apiKey = config?.api_key || 'YOUR_API_KEY';
|
||||
|
||||
let localIp = '127.0.0.1';
|
||||
const interfaces = networkInterfaces();
|
||||
for (const iface of Object.values(interfaces)) {
|
||||
if (iface) {
|
||||
for (const entry of iface) {
|
||||
if (entry.family === 'IPv4' && !entry.internal) {
|
||||
localIp = entry.address;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback generator mimicking the proxyIdeConfig.service
|
||||
const baseUrl = `http://${localIp}:${port}/v1`;
|
||||
let result: IdeConfigResult;
|
||||
|
||||
switch (normalizedIde) {
|
||||
case 'cursor':
|
||||
result = {
|
||||
ide: normalizedIde,
|
||||
format: 'json',
|
||||
content: { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl },
|
||||
};
|
||||
break;
|
||||
case 'vscode':
|
||||
result = {
|
||||
ide: normalizedIde,
|
||||
format: 'json',
|
||||
content: { 'openai.url': baseUrl, 'openai.apiKey': apiKey },
|
||||
};
|
||||
break;
|
||||
case 'claude-code':
|
||||
result = {
|
||||
ide: normalizedIde,
|
||||
format: 'shell',
|
||||
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
|
||||
};
|
||||
break;
|
||||
case 'jetbrains':
|
||||
result = {
|
||||
ide: normalizedIde,
|
||||
format: 'instructions',
|
||||
content: [
|
||||
'JetBrains AI Assistant Setup:',
|
||||
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
|
||||
'2. Navigate to Languages & Frameworks > AI Assistant.',
|
||||
'3. Under "Custom OpenAI Endpoint", set:',
|
||||
` - Base URL: ${baseUrl}`,
|
||||
` - API Key: ${apiKey}`,
|
||||
'4. Click "Apply" and restart the IDE if prompted.',
|
||||
].join('\n'),
|
||||
};
|
||||
break;
|
||||
case 'opencide':
|
||||
result = {
|
||||
ide: normalizedIde,
|
||||
format: 'env',
|
||||
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
|
||||
};
|
||||
break;
|
||||
case 'opencode':
|
||||
result = {
|
||||
ide: normalizedIde,
|
||||
format: 'json',
|
||||
content: {
|
||||
apiType: 'openai',
|
||||
apiKey: apiKey,
|
||||
baseURL: baseUrl,
|
||||
models: { chat: [defaultModel || 'gemini-3.1-pro-high'] },
|
||||
},
|
||||
};
|
||||
break;
|
||||
default:
|
||||
throw new Error('Unsupported');
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -155,9 +155,11 @@ const IdeConfigResponseSchema = z.object({
|
||||
.object({
|
||||
name: z.string(),
|
||||
settingsPath: z.string(),
|
||||
proxySetting: z.string(),
|
||||
apiKeySetting: z.string(),
|
||||
instructions: z.string(),
|
||||
proxySetting: z.string().optional(),
|
||||
apiKeySetting: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
format: z.enum(['json', 'shell', 'env', 'instructions']).optional(),
|
||||
content: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
@@ -204,10 +206,10 @@ export const proxyAdvancedRouter = os.router({
|
||||
}),
|
||||
|
||||
generateIdeConfig: os
|
||||
.input(z.object({ ide: z.string() }))
|
||||
.input(z.object({ ide: z.string(), defaultModel: z.string().optional() }))
|
||||
.output(IdeConfigResponseSchema)
|
||||
.handler(async ({ input }) => {
|
||||
return generateIdeConfig(input.ide);
|
||||
return generateIdeConfig(input.ide, input.defaultModel);
|
||||
}),
|
||||
|
||||
replayRequest: os
|
||||
|
||||
@@ -37,3 +37,9 @@ export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
|
||||
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
|
||||
return svc;
|
||||
}
|
||||
|
||||
export function getServiceOptional<K extends keyof ProxyAdvancedRegistry>(
|
||||
key: K,
|
||||
): ProxyAdvancedRegistry[K] {
|
||||
return proxyAdvancedRegistry[key];
|
||||
}
|
||||
+67
-156
@@ -1,19 +1,10 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import React, { useRef } from 'react';
|
||||
import { Link, Outlet, useLocation } from '@tanstack/react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { StatusBar } from '@/components/StatusBar';
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Settings,
|
||||
Network,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
RefreshCw,
|
||||
BarChart3,
|
||||
} from 'lucide-react';
|
||||
import { LayoutDashboard, Settings, Network, RefreshCw, BarChart3 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { getLocalizedErrorMessage } from '@/utils/errorMessages';
|
||||
@@ -25,17 +16,6 @@ export const MainLayout: React.FC = () => {
|
||||
const { toast } = useToast();
|
||||
const hasShownRouteErrorToastRef = useRef(false);
|
||||
|
||||
// Initialize state from localStorage if available, default to false (expanded)
|
||||
const [isCollapsed, setIsCollapsed] = useState(() => {
|
||||
const saved = localStorage.getItem('sidebar-collapsed');
|
||||
return saved ? JSON.parse(saved) : false;
|
||||
});
|
||||
|
||||
// Persist state changes
|
||||
useEffect(() => {
|
||||
localStorage.setItem('sidebar-collapsed', JSON.stringify(isCollapsed));
|
||||
}, [isCollapsed]);
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
to: '/',
|
||||
@@ -61,146 +41,77 @@ export const MainLayout: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div className="bg-background text-foreground flex h-screen flex-col overflow-hidden">
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={cn(
|
||||
'relative flex flex-col border-r border-white/[0.06] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||
isCollapsed ? 'w-[60px]' : 'w-56',
|
||||
)}
|
||||
>
|
||||
{/* Collapse toggle */}
|
||||
<button
|
||||
className={cn(
|
||||
'absolute -right-3 top-7 z-10 flex h-6 w-6 items-center justify-center rounded-full',
|
||||
'bg-muted border border-white/[0.1] text-muted-foreground',
|
||||
'opacity-0 transition-all duration-200 hover:bg-white/[0.1] hover:text-foreground',
|
||||
'group-hover:opacity-100',
|
||||
)}
|
||||
style={{ opacity: 0 }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0')}
|
||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
) : (
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
)}
|
||||
</button>
|
||||
<header className="flex h-12 items-center border-b border-white/[0.06] px-4">
|
||||
<div className="flex shrink-0 items-center gap-2.5">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
||||
<img src={iconPng} alt="Gemini Nexus Logo" className="h-full w-full object-contain" />
|
||||
</div>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight whitespace-nowrap">
|
||||
Gemini Nexus
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
|
||||
<div className="flex items-center gap-2.5 overflow-hidden whitespace-nowrap">
|
||||
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
||||
<img
|
||||
src={iconPng}
|
||||
alt="Gemini Nexus Logo"
|
||||
className="h-full w-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
<nav className="mx-6 flex flex-1 items-center justify-center gap-1 overflow-x-auto">
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.to;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'overflow-hidden transition-all duration-300',
|
||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||
'flex items-center gap-2 rounded-md px-3 py-2 text-[13px] font-medium whitespace-nowrap transition-colors',
|
||||
isActive
|
||||
? 'text-foreground bg-white/[0.08]'
|
||||
: 'text-muted-foreground hover:text-foreground hover:bg-white/[0.04]',
|
||||
)}
|
||||
>
|
||||
<h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
|
||||
<item.icon className="h-[16px] w-[16px]" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="shrink-0">
|
||||
<StatusBar />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 overflow-auto">
|
||||
<ErrorBoundary
|
||||
resetKeys={[location.pathname]}
|
||||
onReset={() => {
|
||||
hasShownRouteErrorToastRef.current = false;
|
||||
}}
|
||||
onError={(error) => {
|
||||
if (hasShownRouteErrorToastRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('error.generic'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
hasShownRouteErrorToastRef.current = true;
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<div className="rounded-xl border border-dashed border-white/[0.06] p-8 text-center">
|
||||
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
|
||||
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('action.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 space-y-1 px-2 pt-2">
|
||||
<TooltipProvider>
|
||||
{navItems.map((item) => {
|
||||
const isActive = location.pathname === item.to;
|
||||
|
||||
if (isCollapsed) {
|
||||
return (
|
||||
<Tooltip key={item.to} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-[18px] w-[18px]" />
|
||||
<span className="sr-only">{item.label}</span>
|
||||
</Link>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="text-xs">
|
||||
{item.label}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
|
||||
isActive
|
||||
? 'bg-white/[0.08] text-foreground'
|
||||
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-[18px] w-[18px]" />
|
||||
{item.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</TooltipProvider>
|
||||
</nav>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="border-t border-white/[0.06] p-2">
|
||||
<StatusBar isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Content Area */}
|
||||
<main className="flex-1 overflow-auto transition-all duration-300">
|
||||
<ErrorBoundary
|
||||
resetKeys={[location.pathname]}
|
||||
onReset={() => {
|
||||
hasShownRouteErrorToastRef.current = false;
|
||||
}}
|
||||
onError={(error) => {
|
||||
if (hasShownRouteErrorToastRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: t('error.generic'),
|
||||
description: getLocalizedErrorMessage(error, t),
|
||||
variant: 'destructive',
|
||||
});
|
||||
hasShownRouteErrorToastRef.current = true;
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<div className="mx-auto max-w-3xl p-6">
|
||||
<div className="rounded-xl border border-white/[0.06] border-dashed p-8 text-center">
|
||||
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
||||
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
|
||||
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
{t('action.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<Outlet />
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -162,6 +162,11 @@ function cleanJsonSchemaRecursive(value: any) {
|
||||
// 4. Physically remove "hard" blacklist items that interfere with generation
|
||||
const hardRemoveFields = [
|
||||
'$schema',
|
||||
'$id',
|
||||
'$comment',
|
||||
'$anchor',
|
||||
'$dynamicRef',
|
||||
'$dynamicAnchor',
|
||||
'additionalProperties',
|
||||
'enumCaseInsensitive',
|
||||
'enumNormalizeWhitespace',
|
||||
@@ -170,7 +175,15 @@ function cleanJsonSchemaRecursive(value: any) {
|
||||
'const',
|
||||
'examples',
|
||||
// Advanced logic fields common in MCP tools but unsupported by Gemini
|
||||
'patternProperties',
|
||||
'propertyNames',
|
||||
'additionalItems',
|
||||
'prefixItems',
|
||||
'contains',
|
||||
'minContains',
|
||||
'maxContains',
|
||||
'unevaluatedProperties',
|
||||
'unevaluatedItems',
|
||||
'anyOf',
|
||||
'oneOf',
|
||||
'allOf',
|
||||
|
||||
@@ -62,7 +62,9 @@ export class QuotaService {
|
||||
logger.warn(`⚠️ [${email}] loadCodeAssist failed: Status: ${res.status}`);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
logger.error(`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.error(
|
||||
`❌ [${email}] loadCodeAssist Network Error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
|
||||
return [undefined, undefined];
|
||||
@@ -174,7 +176,9 @@ export class QuotaService {
|
||||
lastError = new Error(`HTTP ${status} - ${text}`);
|
||||
shouldFallback = !isNumber(status);
|
||||
} else {
|
||||
logger.warn(`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
logger.warn(
|
||||
`Request Failed at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
lastError = error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
|
||||
@@ -182,7 +186,12 @@ export class QuotaService {
|
||||
logger.warn(`Quota API request failed at ${endpoint}, falling back to next endpoint`);
|
||||
await new Promise((r) => setTimeout(r, 1000));
|
||||
} else {
|
||||
throw lastError ?? new Error(`Quota query failed: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw (
|
||||
lastError ??
|
||||
new Error(
|
||||
`Quota query failed: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ export const nivoTheme: PartialTheme = {
|
||||
},
|
||||
legend: {
|
||||
text: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontSize: 11,
|
||||
fill: '#6b7280',
|
||||
},
|
||||
@@ -45,7 +45,7 @@ export const nivoTheme: PartialTheme = {
|
||||
},
|
||||
legends: {
|
||||
text: {
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontSize: 11,
|
||||
fill: '#6b7280',
|
||||
},
|
||||
@@ -54,7 +54,7 @@ export const nivoTheme: PartialTheme = {
|
||||
container: {
|
||||
background: '#16161e',
|
||||
color: '#e8eaed',
|
||||
fontFamily: "Inter, sans-serif",
|
||||
fontFamily: 'Inter, sans-serif',
|
||||
fontSize: 12,
|
||||
borderRadius: 8,
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
|
||||
+11
-6
@@ -30,6 +30,12 @@ import { ConfigManager } from './ipc/config/manager';
|
||||
import { AppConfig } from './types/config';
|
||||
import { isAutoStartLaunch, syncAutoStart } from './utils/autoStart';
|
||||
import { safeStringifyPacket } from './utils/sensitiveDataMasking';
|
||||
import { IS_DEV_ENVIRONMENT } from './utils/paths';
|
||||
|
||||
// Set app name early so Electron's userData path reflects the environment
|
||||
if (IS_DEV_ENVIRONMENT) {
|
||||
app.setName('Gemini Nexus Dev');
|
||||
}
|
||||
|
||||
const packetLogPath = path.join(app.getPath('userData'), 'orpc_packets.log');
|
||||
|
||||
@@ -63,7 +69,7 @@ if (squirrelStartup) {
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const inDevelopment = process.env.NODE_ENV === 'development';
|
||||
const inDevelopment = IS_DEV_ENVIRONMENT;
|
||||
const debugHttpProxy = process.env.http_proxy?.trim() || process.env.HTTP_PROXY?.trim();
|
||||
const debugHttpsProxy = process.env.https_proxy?.trim() || process.env.HTTPS_PROXY?.trim();
|
||||
const debugNoProxy = process.env.no_proxy?.trim() || process.env.NO_PROXY?.trim();
|
||||
@@ -116,6 +122,8 @@ function isRunningFromExpectedInstallDir() {
|
||||
localAppData: process.env.LOCALAPPDATA,
|
||||
appName: app.getName(),
|
||||
execPath: process.execPath,
|
||||
programFiles: process.env.ProgramFiles,
|
||||
programFilesX86: process.env['ProgramFiles(x86)'],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -140,6 +148,7 @@ function showWindowsInstallNoticeIfNeeded() {
|
||||
hasShownInstallNotice = true;
|
||||
const language = resolveInstallNoticeLanguage({
|
||||
configLanguage: startupConfig?.language,
|
||||
preferredLanguages: app.getPreferredSystemLanguages(),
|
||||
locale: app.getLocale(),
|
||||
});
|
||||
const text = getInstallNoticeText(language);
|
||||
@@ -164,11 +173,7 @@ function showWindowsInstallNoticeIfNeeded() {
|
||||
});
|
||||
}
|
||||
|
||||
const isDev = process.env.NODE_ENV === 'development' || !app.isPackaged;
|
||||
|
||||
if (isDev) {
|
||||
app.setName('Gemini Nexus Dev');
|
||||
}
|
||||
const isDev = IS_DEV_ENVIRONMENT;
|
||||
|
||||
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
||||
|
||||
|
||||
+5
-6
@@ -10,16 +10,15 @@ import os from 'os';
|
||||
let sentryEnabled = false;
|
||||
try {
|
||||
const home = os.homedir();
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
const appName = isDev ? 'Gemini Nexus Dev' : 'Gemini Nexus';
|
||||
let appDataPath = '';
|
||||
if (process.platform === 'win32') {
|
||||
appDataPath = path.join(
|
||||
process.env.APPDATA || path.join(home, 'AppData', 'Roaming'),
|
||||
'Gemini Nexus',
|
||||
);
|
||||
appDataPath = path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), appName);
|
||||
} else if (process.platform === 'darwin') {
|
||||
appDataPath = path.join(home, 'Library', 'Application Support', 'Gemini Nexus');
|
||||
appDataPath = path.join(home, 'Library', 'Application Support', appName);
|
||||
} else {
|
||||
appDataPath = path.join(home, '.config', 'Gemini Nexus');
|
||||
appDataPath = path.join(home, '.config', appName);
|
||||
}
|
||||
|
||||
const configPath = path.join(appDataPath, 'gui_config.json');
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useTranslation } from 'react-i18next';
|
||||
|
||||
function HomePage() {
|
||||
return (
|
||||
<div className="container mx-auto max-w-4xl p-6">
|
||||
<div className="container mx-auto max-w-6xl p-6">
|
||||
<CloudAccountList />
|
||||
</div>
|
||||
);
|
||||
|
||||
+82
-41
@@ -26,7 +26,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2, Copy, Code, Terminal, Eye, EyeOff, ServerOff, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
Copy,
|
||||
Code,
|
||||
Terminal,
|
||||
Eye,
|
||||
EyeOff,
|
||||
ServerOff,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -37,7 +47,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||
import { ProxyAdvancedTab } from '@/components/proxy/ProxyAdvancedTab';
|
||||
import { ToolsTab } from '@/components/proxy/advanced/ToolsTab';
|
||||
import { flatMap, sortBy, uniq } from 'lodash-es';
|
||||
|
||||
type ProxyProtocol = 'openai' | 'anthropic';
|
||||
@@ -304,14 +314,14 @@ print(response.choices[0].message.content)`;
|
||||
<TabsList className="grid w-full grid-cols-4">
|
||||
<TabsTrigger value="status">Status</TabsTrigger>
|
||||
<TabsTrigger value="routing">Routing</TabsTrigger>
|
||||
<TabsTrigger value="tools">Tools</TabsTrigger>
|
||||
<TabsTrigger value="api-docs">API Docs</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Status Tab */}
|
||||
<TabsContent value="status" className="space-y-5">
|
||||
{/* Service Control Card */}
|
||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -411,7 +421,11 @@ print(response.choices[0].message.content)`;
|
||||
<Copy size={14} className="mr-1" />
|
||||
{t('proxy.copy')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setIsRegenerateDialogOpen(true)}>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsRegenerateDialogOpen(true)}
|
||||
>
|
||||
{t('proxy.regenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -420,7 +434,7 @@ print(response.choices[0].message.content)`;
|
||||
<Badge variant="destructive">
|
||||
{proxyConfig.api_key ? 'Weak key' : 'No API key'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{proxyConfig.api_key
|
||||
? 'API key is too short for secure operation'
|
||||
: 'Generate or set an API key to secure the proxy'}
|
||||
@@ -431,7 +445,9 @@ print(response.choices[0].message.content)`;
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('proxy.regenerateConfirm.title')}</DialogTitle>
|
||||
<DialogDescription>{t('proxy.regenerateConfirm.description')}</DialogDescription>
|
||||
<DialogDescription>
|
||||
{t('proxy.regenerateConfirm.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsRegenerateDialogOpen(false)}>
|
||||
@@ -457,7 +473,9 @@ print(response.choices[0].message.content)`;
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('proxy.config.auto_start')}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t('proxy.config.auto_start_desc')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('proxy.config.auto_start_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={proxyConfig.auto_start}
|
||||
@@ -470,7 +488,7 @@ print(response.choices[0].message.content)`;
|
||||
</Card>
|
||||
|
||||
{/* Account Quota Context */}
|
||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>Account Pool</CardTitle>
|
||||
<CardDescription>Quota and routing context</CardDescription>
|
||||
@@ -481,10 +499,14 @@ print(response.choices[0].message.content)`;
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-[13px] font-medium">Primary Account</p>
|
||||
<p className="text-muted-foreground font-mono text-[13px]">{accounts[0].email}</p>
|
||||
<p className="text-muted-foreground font-mono text-[13px]">
|
||||
{accounts[0].email}
|
||||
</p>
|
||||
</div>
|
||||
{accounts.length > 1 && (
|
||||
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
|
||||
<Badge variant="outline">
|
||||
Pool Mode: Active ({accounts.length} accounts)
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="text-xs" asChild>
|
||||
@@ -506,7 +528,7 @@ print(response.choices[0].message.content)`;
|
||||
<>
|
||||
{/* Local Access Info Banner */}
|
||||
<div className="flex flex-col gap-2 rounded-xl border border-blue-500/20 bg-blue-500/5 p-3 text-[13px] text-blue-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-semibold">{t('proxy.config.local_access')}</div>
|
||||
<code className="rounded-md bg-blue-500/10 px-1.5 py-0.5 font-mono text-xs select-all">
|
||||
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
|
||||
@@ -516,9 +538,7 @@ print(response.choices[0].message.content)`;
|
||||
size="icon"
|
||||
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
||||
onClick={() =>
|
||||
copyToClipboard(
|
||||
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
|
||||
)
|
||||
copyToClipboard(`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`)
|
||||
}
|
||||
>
|
||||
<Copy size={14} />
|
||||
@@ -572,9 +592,14 @@ print(response.choices[0].message.content)`;
|
||||
},
|
||||
];
|
||||
return items.map((metric) => (
|
||||
<Card key={metric.label} className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card
|
||||
key={metric.label}
|
||||
className="bg-card border border-white/[0.06] shadow-none"
|
||||
>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">{metric.label}</p>
|
||||
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||
{metric.label}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<span className="font-mono text-xl font-semibold tabular-nums">
|
||||
{metric.value}
|
||||
@@ -592,7 +617,9 @@ print(response.choices[0].message.content)`;
|
||||
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
|
||||
<div className="space-y-1">
|
||||
<p className="text-base font-semibold">Proxy is not running</p>
|
||||
<p className="text-muted-foreground text-[13px]">Start the proxy to enable local API access</p>
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
Start the proxy to enable local API access
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
@@ -609,19 +636,19 @@ print(response.choices[0].message.content)`;
|
||||
|
||||
{/* Routing Tab */}
|
||||
<TabsContent value="routing" className="space-y-5">
|
||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
|
||||
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
|
||||
<div>Requested Model</div>
|
||||
<div>Resolved Model</div>
|
||||
<div>Provider</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-center p-3 text-[13px]">
|
||||
<div className="grid grid-cols-3 items-center gap-4 p-3 text-[13px]">
|
||||
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
|
||||
<Select
|
||||
value={resolveAnthropicMappingValue(
|
||||
@@ -649,7 +676,7 @@ print(response.choices[0].message.content)`;
|
||||
</Select>
|
||||
<Badge variant="secondary">Anthropic</Badge>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-4 items-center border-t border-white/[0.06] p-3 text-[13px]">
|
||||
<div className="grid grid-cols-3 items-center gap-4 border-t border-white/[0.06] p-3 text-[13px]">
|
||||
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
|
||||
<Select
|
||||
value={resolveAnthropicMappingValue(
|
||||
@@ -696,7 +723,7 @@ print(response.choices[0].message.content)`;
|
||||
</Card>
|
||||
|
||||
{/* Current Mappings */}
|
||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
||||
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
||||
@@ -706,10 +733,12 @@ print(response.choices[0].message.content)`;
|
||||
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
||||
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
||||
return mappingEntries.length === 0 && customEntries.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t('proxy.routing.noMapping')}</p>
|
||||
<p className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.routing.noMapping')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||
<div className="text-muted-foreground grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase">
|
||||
<div>{t('proxy.mapping.maps_to')}</div>
|
||||
<div>{t('settings.modelMapping.targetGemini')}</div>
|
||||
<div>Provider</div>
|
||||
@@ -717,7 +746,7 @@ print(response.choices[0].message.content)`;
|
||||
{mappingEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
|
||||
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
|
||||
>
|
||||
<div className="font-mono text-xs">{key}</div>
|
||||
<div className="font-mono text-xs">{value}</div>
|
||||
@@ -727,7 +756,7 @@ print(response.choices[0].message.content)`;
|
||||
{customEntries.map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
|
||||
className="grid grid-cols-3 items-center gap-4 border-b border-white/[0.06] p-3 last:border-0"
|
||||
>
|
||||
<div className="font-mono text-xs">{key}</div>
|
||||
<div className="font-mono text-xs">{value}</div>
|
||||
@@ -741,22 +770,30 @@ print(response.choices[0].message.content)`;
|
||||
</Card>
|
||||
|
||||
{/* Upstream Proxy Status */}
|
||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
||||
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
||||
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.status')}</span>
|
||||
<span className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.routing.status')}
|
||||
</span>
|
||||
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
|
||||
{proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')}
|
||||
{proxyConfig.upstream_proxy?.enabled
|
||||
? t('proxy.routing.enabled')
|
||||
: t('proxy.routing.disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
{proxyConfig.upstream_proxy?.enabled && (
|
||||
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
||||
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.url')}</span>
|
||||
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
|
||||
<span className="text-muted-foreground text-[13px]">
|
||||
{t('proxy.routing.url')}
|
||||
</span>
|
||||
<span className="font-mono text-xs">
|
||||
{proxyConfig.upstream_proxy?.url || '—'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -765,7 +802,7 @@ print(response.choices[0].message.content)`;
|
||||
|
||||
{/* API Docs Tab */}
|
||||
<TabsContent value="api-docs" className="space-y-5">
|
||||
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||
<Card className="bg-card border border-white/[0.06] shadow-none">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Code size={20} />
|
||||
@@ -812,7 +849,11 @@ print(response.choices[0].message.content)`;
|
||||
{provider}
|
||||
</SelectLabel>
|
||||
{models.map((modelId) => (
|
||||
<SelectItem key={modelId} value={modelId} className="font-mono text-xs">
|
||||
<SelectItem
|
||||
key={modelId}
|
||||
value={modelId}
|
||||
className="font-mono text-xs"
|
||||
>
|
||||
{modelId}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -853,7 +894,7 @@ print(response.choices[0].message.content)`;
|
||||
{/* cURL */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
|
||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
|
||||
onClick={() => setShowCurl(!showCurl)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -876,7 +917,7 @@ print(response.choices[0].message.content)`;
|
||||
</div>
|
||||
</div>
|
||||
{showCurl && (
|
||||
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{getCurlExample(activeModelTab)}
|
||||
</pre>
|
||||
)}
|
||||
@@ -885,7 +926,7 @@ print(response.choices[0].message.content)`;
|
||||
{/* Python */}
|
||||
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||
<div
|
||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
|
||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium transition-colors hover:bg-white/[0.02]"
|
||||
onClick={() => setShowPython(!showPython)}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
@@ -908,7 +949,7 @@ print(response.choices[0].message.content)`;
|
||||
</div>
|
||||
</div>
|
||||
{showPython && (
|
||||
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
|
||||
{getPythonExample(activeModelTab)}
|
||||
</pre>
|
||||
)}
|
||||
@@ -918,9 +959,9 @@ print(response.choices[0].message.content)`;
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* Advanced Tab */}
|
||||
<TabsContent value="advanced">
|
||||
<ProxyAdvancedTab proxyConfig={proxyConfig} />
|
||||
{/* Tools Tab */}
|
||||
<TabsContent value="tools">
|
||||
<ToolsTab availableModelIds={availableModelIds} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
+15
-11
@@ -92,7 +92,7 @@ function SettingsPage() {
|
||||
|
||||
{/* --- GENERAL TAB --- */}
|
||||
<TabsContent value="general" className="space-y-5">
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
||||
@@ -139,7 +139,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Account Settings Card */}
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||
@@ -149,7 +149,9 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_refresh')}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t('settings.account.auto_refresh_desc')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.account.auto_refresh_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_refresh || false}
|
||||
@@ -165,7 +167,9 @@ function SettingsPage() {
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label>{t('settings.account.auto_sync')}</Label>
|
||||
<p className="text-muted-foreground text-xs">{t('settings.account.auto_sync_desc')}</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t('settings.account.auto_sync_desc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={config?.auto_sync || false}
|
||||
@@ -180,7 +184,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{isAutoStartSupported && (
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||
@@ -211,7 +215,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.about.description')}</CardDescription>
|
||||
@@ -230,7 +234,7 @@ function SettingsPage() {
|
||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||
<button
|
||||
onClick={() => openLogDirectory()}
|
||||
className="flex items-center gap-2 font-medium text-accent hover:text-accent/80 transition-colors"
|
||||
className="text-accent hover:text-accent/80 flex items-center gap-2 font-medium transition-colors"
|
||||
>
|
||||
<FolderOpen className="h-4 w-4" />
|
||||
<span>{t('settings.openLogDir')}</span>
|
||||
@@ -240,7 +244,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Privacy & Error Reporting Card */}
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||
@@ -267,7 +271,7 @@ function SettingsPage() {
|
||||
</Card>
|
||||
|
||||
{/* Notifications Card */}
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
||||
@@ -327,7 +331,7 @@ function SettingsPage() {
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 rounded-lg border border-white/[0.08] bg-muted/50 px-2 py-1 text-center text-[13px] text-foreground focus:outline-none focus:border-accent/50"
|
||||
className="bg-muted/50 text-foreground focus:border-accent/50 w-16 rounded-lg border border-white/[0.08] px-2 py-1 text-center text-[13px] focus:outline-none"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">%</span>
|
||||
</div>
|
||||
@@ -343,7 +347,7 @@ function SettingsPage() {
|
||||
|
||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||
<TabsContent value="proxy" className="space-y-5">
|
||||
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||
<Card className="rounded-none border-0 border-b border-white/5 bg-transparent pb-6 shadow-none last:border-0">
|
||||
<CardHeader className="px-0 pt-4">
|
||||
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
||||
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
||||
|
||||
+277
-215
@@ -2,7 +2,6 @@ import { useMemo, useState } from 'react';
|
||||
import { createFileRoute } from '@tanstack/react-router';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ResponsiveLine } from '@nivo/line';
|
||||
import { ResponsiveBar } from '@nivo/bar';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -10,18 +9,23 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ipc } from '@/ipc/manager';
|
||||
import { useCloudAccounts } from '@/hooks/useCloudAccounts';
|
||||
import { useAppConfig } from '@/hooks/useAppConfig';
|
||||
import { StatCard } from '@/components/usage/StatCard';
|
||||
import { ChartCard } from '@/components/usage/ChartCard';
|
||||
import {
|
||||
COLOR_PROMPT,
|
||||
COLOR_COMPLETION,
|
||||
PIE_COLORS,
|
||||
LineTooltip,
|
||||
} from '@/components/usage/NivoTooltip';
|
||||
import { ForecastCard } from '@/components/usage/ForecastCard';
|
||||
import { ModelQuotaBar } from '@/components/usage/ModelQuotaBar';
|
||||
import { CostReportCard, ModelCostEntry } from '@/components/usage/CostReportCard';
|
||||
import { ModelPricingDialog } from '@/components/usage/ModelPricingDialog';
|
||||
import { calculateCost } from '@/constants/pricing';
|
||||
import { COLOR_PROMPT, COLOR_COMPLETION, LineTooltip } from '@/components/usage/NivoTooltip';
|
||||
import { nivoTheme } from '@/lib/nivo-theme';
|
||||
import { MonitorTab } from '@/components/usage/MonitorTab';
|
||||
import {
|
||||
Activity,
|
||||
Layers,
|
||||
@@ -29,6 +33,10 @@ import {
|
||||
MessageSquare,
|
||||
Radio,
|
||||
Zap,
|
||||
DollarSign,
|
||||
Info,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
@@ -129,10 +137,11 @@ function UsagePage() {
|
||||
const [range, setRange] = useState<TimeRange>('7d');
|
||||
const ALL_ACCOUNTS = '__all__';
|
||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||
const [isPricingDialogOpen, setIsPricingDialogOpen] = useState(false);
|
||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||
const { config, saveConfig } = useAppConfig();
|
||||
|
||||
const accountFilter =
|
||||
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
||||
const accountFilter = selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
||||
|
||||
/* ───── Current period data ───── */
|
||||
const {
|
||||
@@ -203,7 +212,6 @@ function UsagePage() {
|
||||
end,
|
||||
});
|
||||
},
|
||||
enabled: range === '24h',
|
||||
refetchInterval: 15_000,
|
||||
refetchIntervalInBackground: false,
|
||||
staleTime: 30_000,
|
||||
@@ -211,10 +219,7 @@ function UsagePage() {
|
||||
|
||||
/* ───── Derived data ───── */
|
||||
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
||||
const prevTotals = useMemo(
|
||||
() => sumBuckets(prevDailyData || []),
|
||||
[prevDailyData],
|
||||
);
|
||||
const prevTotals = useMemo(() => sumBuckets(prevDailyData || []), [prevDailyData]);
|
||||
|
||||
const promptRatio = totals.totalTokens
|
||||
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
||||
@@ -222,33 +227,22 @@ function UsagePage() {
|
||||
const completionRatio = totals.totalTokens
|
||||
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
||||
: 0;
|
||||
const avgReqSize = totals.requests
|
||||
? Math.round(totals.totalTokens / totals.requests)
|
||||
: 0;
|
||||
const avgReqSize = totals.requests ? Math.round(totals.totalTokens / totals.requests) : 0;
|
||||
|
||||
const sparklineTotal = useMemo(
|
||||
() => (dailyData || []).map((d) => d.totalTokens),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklinePrompt = useMemo(
|
||||
() => (dailyData || []).map((d) => d.promptTokens),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklineTotal = useMemo(() => (dailyData || []).map((d) => d.totalTokens), [dailyData]);
|
||||
const sparklinePrompt = useMemo(() => (dailyData || []).map((d) => d.promptTokens), [dailyData]);
|
||||
const sparklineCompletion = useMemo(
|
||||
() => (dailyData || []).map((d) => d.completionTokens),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklineRequests = useMemo(
|
||||
() => (dailyData || []).map((d) => d.requests),
|
||||
[dailyData],
|
||||
);
|
||||
const sparklineRequests = useMemo(() => (dailyData || []).map((d) => d.requests), [dailyData]);
|
||||
|
||||
// Area chart — Nivo line format
|
||||
const areaChartData = useMemo(() => {
|
||||
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
||||
return [
|
||||
{
|
||||
id: 'Prompt',
|
||||
id: 'Input Tokens',
|
||||
color: COLOR_PROMPT,
|
||||
data: source.map((d) => ({
|
||||
x: formatBucketLabel(d.bucket, range),
|
||||
@@ -256,7 +250,7 @@ function UsagePage() {
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: 'Completion',
|
||||
id: 'Output Tokens',
|
||||
color: COLOR_COMPLETION,
|
||||
data: source.map((d) => ({
|
||||
x: formatBucketLabel(d.bucket, range),
|
||||
@@ -266,30 +260,73 @@ function UsagePage() {
|
||||
];
|
||||
}, [dailyData, hourlyData, range]);
|
||||
|
||||
// Model ranking bar data
|
||||
const modelBarData = useMemo(() => {
|
||||
if (!modelData?.length) {
|
||||
return [];
|
||||
}
|
||||
return [...modelData]
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 8)
|
||||
.map((m) => ({
|
||||
model:
|
||||
m.model.length > 22 ? m.model.slice(0, 22) + '…' : m.model,
|
||||
tokens: m.totalTokens,
|
||||
}));
|
||||
}, [modelData]);
|
||||
// Model quota bar data and cost estimation
|
||||
const { topModelsData, costReport, totalCost, hasQuotaWarning } = useMemo(() => {
|
||||
let cost = 0;
|
||||
let warning = false;
|
||||
const report: ModelCostEntry[] = [];
|
||||
|
||||
// Top models mini-list
|
||||
const topModels = useMemo(() => {
|
||||
if (!modelData?.length) {
|
||||
return [];
|
||||
return { topModelsData: [], costReport: [], totalCost: 0, hasQuotaWarning: false };
|
||||
}
|
||||
return [...modelData]
|
||||
|
||||
const accountQuotas =
|
||||
selectedAccountId !== ALL_ACCOUNTS
|
||||
? accounts?.find((a) => a.id === selectedAccountId)?.quota?.models
|
||||
: null;
|
||||
|
||||
const processed = [...modelData]
|
||||
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||
.slice(0, 5);
|
||||
}, [modelData]);
|
||||
.map((m) => {
|
||||
const inputCost = calculateCost(m.model, m.promptTokens, 0, config?.custom_model_pricing);
|
||||
const outputCost = calculateCost(
|
||||
m.model,
|
||||
0,
|
||||
m.completionTokens,
|
||||
config?.custom_model_pricing,
|
||||
);
|
||||
const mTotalCost = calculateCost(
|
||||
m.model,
|
||||
m.promptTokens,
|
||||
m.completionTokens,
|
||||
config?.custom_model_pricing,
|
||||
);
|
||||
|
||||
cost += mTotalCost;
|
||||
|
||||
report.push({
|
||||
model: m.model,
|
||||
inputTokens: m.promptTokens,
|
||||
outputTokens: m.completionTokens,
|
||||
inputCost,
|
||||
outputCost,
|
||||
totalCost: mTotalCost,
|
||||
});
|
||||
|
||||
// Use percentage if available directly from the API response
|
||||
const percentage = accountQuotas?.[m.model]?.percentage;
|
||||
if (percentage && percentage >= 80) {
|
||||
warning = true;
|
||||
}
|
||||
|
||||
// Since limit tokens isn't stored in quotas exactly, we just use undefined
|
||||
// to not display limit stats in the bar but just usage
|
||||
const limitTokens = undefined;
|
||||
|
||||
return {
|
||||
model: m.model,
|
||||
tokens: m.totalTokens,
|
||||
limit: limitTokens,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
topModelsData: processed.slice(0, 8),
|
||||
costReport: report.sort((a, b) => b.totalCost - a.totalCost),
|
||||
totalCost: cost,
|
||||
hasQuotaWarning: warning,
|
||||
};
|
||||
}, [modelData, accounts, selectedAccountId]);
|
||||
|
||||
const isAreaEmpty =
|
||||
range === '24h'
|
||||
@@ -299,6 +336,14 @@ function UsagePage() {
|
||||
const areaError = range === '24h' ? hourlyError : dailyError;
|
||||
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
||||
|
||||
const handleSavePricing = async (
|
||||
pricing: Record<string, { input: number; output: number; source: string }>,
|
||||
) => {
|
||||
if (config) {
|
||||
await saveConfig({ ...config, custom_model_pricing: pricing });
|
||||
}
|
||||
};
|
||||
|
||||
/* ───── Render ───── */
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-auto">
|
||||
@@ -311,9 +356,7 @@ function UsagePage() {
|
||||
</h1>
|
||||
<div className="flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
||||
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
|
||||
<span className="text-[10px] font-medium text-emerald-400">
|
||||
Live
|
||||
</span>
|
||||
<span className="text-[10px] font-medium text-emerald-400">Live</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
||||
@@ -347,10 +390,7 @@ function UsagePage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Tabs
|
||||
value={range}
|
||||
onValueChange={(v) => setRange(v as TimeRange)}
|
||||
>
|
||||
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="24h" className="px-3 text-xs">
|
||||
24h
|
||||
@@ -366,75 +406,147 @@ function UsagePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-4 px-6 py-5">
|
||||
{/* ─── Stat cards ─── */}
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={formatNumber(totals.totalTokens)}
|
||||
icon={Zap}
|
||||
isLoading={dailyLoading}
|
||||
accent="amber"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.totalTokens, prevTotals.totalTokens)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineTotal}
|
||||
/>
|
||||
<StatCard
|
||||
label="Prompt Tokens"
|
||||
value={formatNumber(totals.promptTokens)}
|
||||
icon={MessageSquare}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklinePrompt}
|
||||
/>
|
||||
<StatCard
|
||||
label="Completion Tokens"
|
||||
value={formatNumber(totals.completionTokens)}
|
||||
icon={Activity}
|
||||
isLoading={dailyLoading}
|
||||
accent="green"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(
|
||||
totals.completionTokens,
|
||||
prevTotals.completionTokens,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineCompletion}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Requests"
|
||||
value={totals.requests.toLocaleString()}
|
||||
icon={Layers}
|
||||
isLoading={dailyLoading}
|
||||
accent="slate"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.requests, prevTotals.requests)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineRequests}
|
||||
/>
|
||||
<Tabs defaultValue="analytics" className="flex-1 flex flex-col">
|
||||
<div className="px-6 pt-5">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
<TabsTrigger value="monitor">Monitor</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
<TabsContent value="analytics" className="flex-1 space-y-4 px-6 py-5">
|
||||
{hasQuotaWarning && (
|
||||
<Alert
|
||||
variant="destructive"
|
||||
className="border-amber-500/50 bg-amber-500/10 text-amber-500"
|
||||
>
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Quota Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
One or more models have exceeded 80% of their allocated usage limit.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{/* ─── Stat cards ─── */}
|
||||
<TooltipProvider delayDuration={300}>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-5">
|
||||
<StatCard
|
||||
label="Total Tokens"
|
||||
value={formatNumber(totals.totalTokens)}
|
||||
icon={Zap}
|
||||
isLoading={dailyLoading}
|
||||
accent="amber"
|
||||
trend={
|
||||
prevDailyData ? calcTrend(totals.totalTokens, prevTotals.totalTokens) : undefined
|
||||
}
|
||||
sparkline={sparklineTotal}
|
||||
/>
|
||||
<div className="group relative">
|
||||
<StatCard
|
||||
label={
|
||||
<div className="flex cursor-help items-center gap-1.5">
|
||||
Input Tokens
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tokens sent to the model (prompt, context, files).</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
value={formatNumber(totals.promptTokens)}
|
||||
icon={MessageSquare}
|
||||
isLoading={dailyLoading}
|
||||
accent="blue"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklinePrompt}
|
||||
/>
|
||||
</div>
|
||||
<div className="group relative">
|
||||
<StatCard
|
||||
label={
|
||||
<div className="flex cursor-help items-center gap-1.5">
|
||||
Output Tokens
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Tokens generated by the model in response.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
}
|
||||
value={formatNumber(totals.completionTokens)}
|
||||
icon={Activity}
|
||||
isLoading={dailyLoading}
|
||||
accent="green"
|
||||
trend={
|
||||
prevDailyData
|
||||
? calcTrend(totals.completionTokens, prevTotals.completionTokens)
|
||||
: undefined
|
||||
}
|
||||
sparkline={sparklineCompletion}
|
||||
/>
|
||||
</div>
|
||||
<StatCard
|
||||
label="Total Requests"
|
||||
value={totals.requests.toLocaleString()}
|
||||
icon={Layers}
|
||||
isLoading={dailyLoading}
|
||||
accent="slate"
|
||||
trend={prevDailyData ? calcTrend(totals.requests, prevTotals.requests) : undefined}
|
||||
sparkline={sparklineRequests}
|
||||
/>
|
||||
<div className="group relative">
|
||||
<StatCard
|
||||
label={
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<div className="flex cursor-help items-center gap-1.5">
|
||||
Estimated Cost
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="text-muted-foreground/70 h-3.5 w-3.5" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Approximate cost based on configured pricing.</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="ml-2 h-5 w-5 hover:bg-white/10"
|
||||
onClick={() => setIsPricingDialogOpen(true)}
|
||||
>
|
||||
<Settings className="text-muted-foreground h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
value={`$${totalCost.toFixed(2)}`}
|
||||
icon={DollarSign}
|
||||
isLoading={modelLoading}
|
||||
accent="purple"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
|
||||
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
||||
{!dailyLoading && totals.totalTokens > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<div className="col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-card p-4 lg:col-span-2">
|
||||
<div className="bg-card col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] p-4 lg:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
||||
Prompt / Completion
|
||||
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||
Input / Output
|
||||
</span>
|
||||
<span className="font-mono text-xs text-muted-foreground">
|
||||
<span className="text-muted-foreground font-mono text-xs">
|
||||
{formatNumber(totals.totalTokens)} total
|
||||
</span>
|
||||
</div>
|
||||
@@ -461,76 +573,30 @@ function UsagePage() {
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: COLOR_PROMPT }}
|
||||
/>
|
||||
<span className="text-muted-foreground">Prompt</span>
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
{promptRatio}%
|
||||
</span>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<span className="text-foreground font-mono font-semibold">{promptRatio}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className="h-2 w-2 rounded-full"
|
||||
style={{ backgroundColor: COLOR_COMPLETION }}
|
||||
/>
|
||||
<span className="text-muted-foreground">Completion</span>
|
||||
<span className="font-mono font-semibold text-foreground">
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<span className="text-foreground font-mono font-semibold">
|
||||
{completionRatio}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Avg/req:{' '}
|
||||
<span className="font-mono font-medium text-foreground">
|
||||
<span className="text-foreground font-mono font-medium">
|
||||
{formatNumber(avgReqSize)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Top models */}
|
||||
<div className="rounded-xl border border-white/[0.06] bg-card p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
||||
Top Models
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
tokens
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{topModels.map((m, i) => {
|
||||
const max = topModels[0]?.totalTokens || 1;
|
||||
const pct = Math.max(8, (m.totalTokens / max) * 100);
|
||||
return (
|
||||
<div key={m.model} className="group">
|
||||
<div className="mb-0.5 flex items-center justify-between">
|
||||
<span className="max-w-[140px] truncate text-xs text-muted-foreground transition-colors group-hover:text-foreground">
|
||||
{m.model}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] font-medium text-foreground tabular-nums">
|
||||
{formatNumber(m.totalTokens)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
backgroundColor:
|
||||
PIE_COLORS[i % PIE_COLORS.length],
|
||||
opacity: 0.7,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{topModels.length === 0 && (
|
||||
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||
No data
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ForecastCard hourlyData={hourlyData} dailyData={dailyData} range={range} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -542,9 +608,7 @@ function UsagePage() {
|
||||
isLoading={areaLoading}
|
||||
error={areaError}
|
||||
isEmpty={isAreaEmpty}
|
||||
onRetry={() =>
|
||||
range === '24h' ? refetchHourly() : refetchDaily()
|
||||
}
|
||||
onRetry={() => (range === '24h' ? refetchHourly() : refetchDaily())}
|
||||
emptyTitle="No usage data"
|
||||
emptyDescription="Data will appear here once proxy requests are recorded."
|
||||
errorMessage="Failed to load usage data"
|
||||
@@ -579,8 +643,7 @@ function UsagePage() {
|
||||
tickSize: 0,
|
||||
tickPadding: 8,
|
||||
tickRotation: 0,
|
||||
format: (v: number) =>
|
||||
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
|
||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
||||
}}
|
||||
tooltip={LineTooltip}
|
||||
legends={[
|
||||
@@ -617,47 +680,46 @@ function UsagePage() {
|
||||
errorMessage="Failed to load model data"
|
||||
retryLabel="Retry"
|
||||
>
|
||||
<div className="h-[320px]">
|
||||
<ResponsiveBar
|
||||
data={modelBarData}
|
||||
keys={['tokens']}
|
||||
indexBy="model"
|
||||
layout="horizontal"
|
||||
theme={nivoTheme}
|
||||
colors={PIE_COLORS}
|
||||
colorBy="indexValue"
|
||||
margin={{ top: 0, right: 10, bottom: 10, left: 120 }}
|
||||
padding={0.3}
|
||||
borderRadius={4}
|
||||
enableGridX={true}
|
||||
enableGridY={false}
|
||||
gridXValues={4}
|
||||
axisBottom={null}
|
||||
axisLeft={{ tickSize: 0, tickPadding: 8 }}
|
||||
axisTop={null}
|
||||
axisRight={null}
|
||||
enableLabel={true}
|
||||
label={(d) => formatNumber(d.value as number)}
|
||||
labelSkipWidth={40}
|
||||
labelTextColor="#e8eaed"
|
||||
tooltip={({ indexValue, value }) => (
|
||||
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||
<p className="text-foreground mb-1 text-xs font-medium">
|
||||
{indexValue}
|
||||
</p>
|
||||
<span className="text-foreground font-mono text-xs font-medium">
|
||||
{(value as number).toLocaleString()} tokens
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
animate={true}
|
||||
motionConfig="gentle"
|
||||
/>
|
||||
<div className="custom-scrollbar h-[320px] overflow-y-auto pr-2">
|
||||
{topModelsData.map((m) => (
|
||||
<ModelQuotaBar
|
||||
key={m.model}
|
||||
modelName={m.model}
|
||||
usedTokens={m.tokens}
|
||||
limitTokens={m.limit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ChartCard>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Cost Report ─── */}
|
||||
{!dailyLoading && modelData && modelData.length > 0 && (
|
||||
<div className="mt-6">
|
||||
<CostReportCard
|
||||
models={costReport}
|
||||
totalInputCost={costReport.reduce((acc, r) => acc + r.inputCost, 0)}
|
||||
totalOutputCost={costReport.reduce((acc, r) => acc + r.outputCost, 0)}
|
||||
totalCost={totalCost}
|
||||
isLoading={modelLoading}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ─── Modals ─── */}
|
||||
<ModelPricingDialog
|
||||
open={isPricingDialogOpen}
|
||||
onOpenChange={setIsPricingDialogOpen}
|
||||
customPricing={config?.custom_model_pricing || {}}
|
||||
onSavePricing={handleSavePricing}
|
||||
availableModels={modelData?.map((m) => m.model) || []}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="monitor" className="flex-1 px-6 py-5">
|
||||
<MonitorTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,70 +8,97 @@ import { sanitizeObject } from '../../utils/sensitiveDataMasking';
|
||||
const LOG_RETENTION = '30d';
|
||||
const LOG_MAX_SIZE = '50m';
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
let _logger: winston.Logger | null = null;
|
||||
|
||||
if (!fs.existsSync(agentDir)) {
|
||||
try {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to create agent directory for API logs', e);
|
||||
/**
|
||||
* Lazily initializes the API logger on first access.
|
||||
* Avoids creating directories and file transports at module load time,
|
||||
* preventing EACCES errors in CI/test environments.
|
||||
*/
|
||||
function getLogger(): winston.Logger {
|
||||
if (_logger) {
|
||||
return _logger;
|
||||
}
|
||||
|
||||
const agentDir = getAgentDir();
|
||||
|
||||
if (!fs.existsSync(agentDir)) {
|
||||
try {
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
} catch (e) {
|
||||
console.error('Failed to create agent directory for API logs', e);
|
||||
}
|
||||
}
|
||||
|
||||
const fileFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
...meta,
|
||||
};
|
||||
return JSON.stringify(logEntry);
|
||||
}),
|
||||
);
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
...meta,
|
||||
};
|
||||
return JSON.stringify(logEntry);
|
||||
}),
|
||||
);
|
||||
|
||||
const rotateTransport = new DailyRotateFile({
|
||||
filename: path.join(agentDir, 'api-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD-HH',
|
||||
maxSize: LOG_MAX_SIZE,
|
||||
maxFiles: LOG_RETENTION,
|
||||
zippedArchive: false,
|
||||
auditFile: path.join(agentDir, '.api-log-audit.json'),
|
||||
level: 'debug',
|
||||
format: fileFormat,
|
||||
});
|
||||
|
||||
rotateTransport.on('error', (error) => {
|
||||
console.error('DailyRotateFile API transport error', error);
|
||||
});
|
||||
|
||||
const consoleTransport = new winston.transports.Console({
|
||||
level: 'debug',
|
||||
format: consoleFormat,
|
||||
});
|
||||
|
||||
consoleTransport.on('error', (error) => {
|
||||
console.error('Console API transport error', error);
|
||||
});
|
||||
|
||||
_logger = winston.createLogger({
|
||||
level: 'debug',
|
||||
transports: [consoleTransport, rotateTransport],
|
||||
exitOnError: false,
|
||||
});
|
||||
|
||||
return _logger;
|
||||
}
|
||||
|
||||
const fileFormat = winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
...meta,
|
||||
};
|
||||
return JSON.stringify(logEntry);
|
||||
}),
|
||||
);
|
||||
|
||||
const consoleFormat = winston.format.combine(
|
||||
winston.format.colorize({ all: true }),
|
||||
winston.format.printf(({ timestamp, level, message, ...meta }) => {
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
...meta,
|
||||
};
|
||||
return JSON.stringify(logEntry);
|
||||
}),
|
||||
);
|
||||
|
||||
const rotateTransport = new DailyRotateFile({
|
||||
filename: path.join(agentDir, 'api-%DATE%.log'),
|
||||
datePattern: 'YYYY-MM-DD-HH',
|
||||
maxSize: LOG_MAX_SIZE,
|
||||
maxFiles: LOG_RETENTION,
|
||||
zippedArchive: false,
|
||||
auditFile: path.join(agentDir, '.api-log-audit.json'),
|
||||
level: 'debug',
|
||||
format: fileFormat,
|
||||
});
|
||||
|
||||
rotateTransport.on('error', (error) => {
|
||||
console.error('DailyRotateFile API transport error', error);
|
||||
});
|
||||
|
||||
const consoleTransport = new winston.transports.Console({
|
||||
level: 'debug',
|
||||
format: consoleFormat,
|
||||
});
|
||||
|
||||
consoleTransport.on('error', (error) => {
|
||||
console.error('Console API transport error', error);
|
||||
});
|
||||
|
||||
export const apiLogger = winston.createLogger({
|
||||
level: 'debug',
|
||||
transports: [consoleTransport, rotateTransport],
|
||||
exitOnError: false,
|
||||
/** Lazily-initialized API logger instance. */
|
||||
export const apiLogger = new Proxy({} as winston.Logger, {
|
||||
get(_target, prop: string) {
|
||||
const logger = getLogger();
|
||||
const value = logger[prop as keyof winston.Logger];
|
||||
if (typeof value === 'function') {
|
||||
return value.bind(logger);
|
||||
}
|
||||
return value;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
Loaded 100 of 366 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user