Compare commits

..
Author SHA1 Message Date
evandro 7ed730b23b docs: correct the README verification notes, bind address and default port
Drop point-in-time test counts and audit notes that no longer hold, state that
the gateway listens on 0.0.0.0, list two known gaps, and fix the default port
(8045, not 10100) in the pt-BR, es and zh-CN READMEs.
2026-10-10 04:30:10 -03:00
evandro 0a6a8ff4bb test: raise the timeout of the OAuth client suite
The suite re-imports GoogleAPIService after vi.resetModules() in every test;
under the parallel full run the first import exceeded 5 s and failed
(passes alone in about 1 s).
2026-10-10 04:29:40 -03:00
evandro 8e20c32117 feat(a11y): label icon-only buttons
Add accessible names (and aria-pressed for toggles) to the layout switcher,
overflow menu, selection clear, API key reveal/copy and pricing buttons,
reusing existing i18n keys. Replace the hard-coded English 'Menu' sr-only
text on the account cards with common.openMenu.
2026-10-10 04:29:03 -03:00
evandro 2e8657d7a0 perf: bound the OpenAI response cache in ProxyService
responseCache was a plain Map keyed by the JSON of the whole request. Entries
were only removed when the same key was read again after expiring, so every
distinct prompt stayed in memory for the lifetime of the gateway. Replace it
with a TtlCache that prunes expired entries on write and caps the size at 100
entries (TTL unchanged at 60 s).
2026-10-10 04:27:06 -03:00
evandro 1ad15d6e3a fix(security): keep OAuth tokens out of renderer IPC and compare API keys in constant time
The cloud ORPC routes (addGoogleAccount, listCloudAccounts, refreshAccountQuota,
syncLocalAccount) returned the full account including access and refresh
tokens, but the renderer never reads them. Blank the two secrets in those
responses; token metadata (expiry, project id) is kept so the schema holds.
Also compare the presented gateway API key with timingSafeEqual.
2026-10-10 04:25:50 -03:00
evandro 291b8ac91f chore: remove unused modules and dependencies
Delete files with no importers (src/actions/{account,database,window}.ts,
drag-window-region, navigation-menu, ui/navigation-menu, ui/toggle,
ui/toggle-group) and the packages only they or nothing used
(@icons-pack/react-simple-icons, @radix-ui/react-icons,
@sentry/integrations, @radix-ui/react-{navigation-menu,toggle,toggle-group}).
Drop the duplicated Playwright block in .gitignore.
2026-10-10 04:23:12 -03:00
evandro 880b799535 refactor: reduce cyclomatic complexity of request mapping and rate-limit parsing
Pin the current behavior with characterization tests (claude-request-mapper,
model-mapping, rate-limit-reason; 91 cases, all green before the refactor),
then split buildContents, buildGenerationConfig, transformClaudeRequestIn,
resolveModelRoute, detectRateLimitReason and computeRetryAfterSeconds into
small helpers. Also declare the emitted toolConfig on GeminiRequest.
2026-10-10 04:20:22 -03:00
semantic-release-bot cf5b669133 chore(release): 0.15.5
## [0.15.5](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.4...v0.15.5) (2026-10-01)

### 🐛 Bug Fixes

* parse complete retry durations and preserve local IDE configuration ([b193bee](https://github.com/evandrodevbr/GeminiNexus/commit/b193bee03f1aaea5f7173e53ec6cdb787717067c))

### ✅ Tests

* mock OS exports consistently and verify LAN discovery ([fa39fb3](https://github.com/evandrodevbr/GeminiNexus/commit/fa39fb34fef662c11cd8780b562fa8008b982823))
2026-10-01 02:03:18 +00:00
Evandro Fonseca Junior 4b3f193bd0 Merge pull request #11 from evandrodevbr/codex/review-2026-09-30-049051
fix: correct retry timing and preserve local IDE configuration
2026-09-30 23:02:43 -03:00
Evandro Fonseca Junior fa39fb34fe test: mock OS exports consistently and verify LAN discovery 2026-09-30 17:43:09 -03:00
Evandro Fonseca Junior b193bee03f fix: parse complete retry durations and preserve local IDE configuration 2026-09-30 17:36:01 -03:00
semantic-release-bot e8eaa049d8 chore(release): 0.15.4
## [0.15.4](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.3...v0.15.4) (2026-09-14)

### 📝 Documentation

* establish dual license (CC-BY-NC-SA + MIT) and correct attribution ([42466c2](https://github.com/evandrodevbr/GeminiNexus/commit/42466c274b36b5dde2d7d33c9016b2fab05cf551))
* refine READMEs and highlight Windows SmartScreen warning ([0dbae81](https://github.com/evandrodevbr/GeminiNexus/commit/0dbae81353c162b0b95d566493164ea6c02e2fa2))
* rewrite the README with verified behavior and honest limitations ([acb8508](https://github.com/evandrodevbr/GeminiNexus/commit/acb8508bff2f6e32691f394d21d7c8193608cd5e))

### 💄 Styles

* clear the lint and prettier findings so the gates pass ([8ad3eef](https://github.com/evandrodevbr/GeminiNexus/commit/8ad3eef53d88d049a3a25cb54df590073a67b75b))

### 🔧 Continuous Integration

* upload release assets sequentially with retry ([b13f06c](https://github.com/evandrodevbr/GeminiNexus/commit/b13f06c0a3538ff962eae2f6fc07cfc81281bf02))
2026-09-14 11:52:31 +00:00
Evandro Fonseca Junior acb8508bff docs: rewrite the README with verified behavior and honest limitations
- README.md rebuilt from the code: account pool, proxy endpoints, settings paths, verified commands, build/make steps and limitations
- translations get a pointer to the canonical README
2026-09-14 08:51:55 -03:00
Evandro Fonseca Junior 43aa538932 chore(ci): drop trailing whitespace in the publish workflow 2026-09-14 08:51:55 -03:00
Evandro Fonseca Junior 8ad3eef53d style: clear the lint and prettier findings so the gates pass
- prettier formatting across routes, components and tests (no behavior change)
- unused imports and ternary-as-statement replaced with explicit branches
- eslint: scripts/**/*.js are CommonJS, allow require() there; prettier ignores pnpm-lock.yaml
2026-09-14 08:51:55 -03:00
Gemini Nexus Bot 42466c274b docs: establish dual license (CC-BY-NC-SA + MIT) and correct attribution
Tests / Unit Tests (push) Canceled after 0s
Tests / Type Check (push) Canceled after 0s
Release / Semantic Release (push) Successful in 10m18s
- Add LICENSE-MIT for all new code written by evandrodevbr
- Rewrite License section: dual structure (original code CC-BY-NC-SA, new code MIT)
- Add migration notice: project is transitioning to fully open-source MIT
- Rewrite Credits section: accurately attribute what Draculabo created vs what evandrodevbr rewrote
- Remove outdated 'commercial use prohibited' disclaimer
- Applied across all 4 languages (EN, PT-BR, ES, ZH-CN)
2026-05-02 03:14:15 -03:00
Gemini Nexus Bot 0dbae81353 docs: refine READMEs and highlight Windows SmartScreen warning
- Move Windows SmartScreen workaround instructions to a highly visible warning block at the top of the files
- Add SmartScreen instructions to the Installation / Downloads sections
- Standardize headings, hierarchy, and formatting across all 4 languages (EN, PT-BR, ES, ZH-CN)
- Polish text for better flow and clarity in 'Why Gemini Nexus?' and Tech Stack sections
2026-05-02 02:46:14 -03:00
Gemini Nexus Bot a4727713d2 Merge branch 'main' of https://github.com/evandrodevbr/GeminiNexus 2026-05-02 02:28:43 -03:00
Gemini Nexus Bot b13f06c0a3 ci: upload release assets sequentially with retry
Prevents 'HTTP 404: Not Found' and '502 Bad Gateway' errors from GitHub API
when attempting to upload many assets simultaneously using the gh cli.
2026-05-02 02:28:38 -03:00
semantic-release-bot 7a6587cb8b chore(release): 0.15.3
## [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))
2026-05-02 05:13:21 +00:00
Gemini Nexus Bot e1c9a9e34a Merge branch 'main' of https://github.com/evandrodevbr/GeminiNexus 2026-05-02 02:12:25 -03:00
Gemini Nexus Bot 1c26a9e542 build: allow fs-xattr and appdmg to build in pnpm v10 2026-05-02 02:09:53 -03:00
semantic-release-bot 5026be12d2 chore(release): 0.15.2
## [0.15.2](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.1...v0.15.2) (2026-05-02)

### 👷 Build System

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

### ✅ Tests

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

### 👷 Build System

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

### ✨ Features

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

### 🐛 Bug Fixes

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

### ♻️ Code Refactoring

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

Refactored to use a Proxy that lazily initializes the winston logger
on first method call. This eliminates side effects during module
loading while maintaining the same public API.
2026-04-29 03:39:28 -03:00
Gemini Nexus Bot 195af14707 ci: chain publish workflow after release via workflow_run
- Replaced push/release triggers with workflow_run on Release completion
- Added check-release job to verify a new release exists before building
- Simplified publish job to use tag from check-release output
- This solves the GITHUB_TOKEN limitation where events don't trigger
  other workflows
2026-04-29 03:28:43 -03:00
Gemini Nexus Bot a9a492274c ci: restore changelog and git plugins in semantic-release
Branch ruleset now allows GitHub Actions bypass, so the plugins
can push changelog and version bumps back to main.
2026-04-29 03:23:50 -03:00
Gemini Nexus Bot 3cf82a7999 ci: remove @semantic-release/git and @semantic-release/changelog plugins
These plugins push commits directly to main, which conflicts with
branch protection rules (PR required, signed commits). The tag and
GitHub Release are created via API by @semantic-release/github
and don't need push access.
2026-04-29 03:22:07 -03:00
394 changed files with 9287 additions and 54286 deletions

No files matched your search

-2
View File
@@ -18,8 +18,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
-2
View File
@@ -18,8 +18,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
+62 -54
View File
@@ -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
@@ -83,6 +118,8 @@ jobs:
Write-Host "Incompatible WiX Toolset detected: $installed"
choco upgrade wixtoolset --version=3.14.0 --allow-downgrade -y
}
# Ensure 7zip is available for Squirrel.Windows zip operations
choco install 7zip -y
- name: Install dependencies
run: pnpm install --frozen-lockfile
@@ -136,16 +173,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 +191,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 }}
@@ -229,4 +225,16 @@ jobs:
fi
printf 'Uploading %s assets to %s\n' "${#files[@]}" "$RELEASE_TAG"
gh release upload "$RELEASE_TAG" "${files[@]}" --clobber
# Upload files sequentially with retries to prevent GitHub API rate limits and 404/502 errors
for file in "${files[@]}"; do
echo "Uploading $file..."
if ! gh release upload "$RELEASE_TAG" "$file" --clobber; then
echo "::warning::Failed to upload $file. Retrying in 5 seconds..."
sleep 5
if ! gh release upload "$RELEASE_TAG" "$file" --clobber; then
echo "::error::Failed to upload $file after retry."
exit 1
fi
fi
done
-2
View File
@@ -23,8 +23,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
-4
View File
@@ -20,8 +20,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
@@ -44,8 +42,6 @@ jobs:
- name: Install pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
+29 -5
View File
@@ -95,10 +95,6 @@ out/
/playwright-report/
/blob-report/
/playwright/.cache/
/test-results/
/playwright-report/
/blob-report/
/playwright/.cache/
# Tanstack
.tanstack/
@@ -125,4 +121,32 @@ cli/aliases.json
accounts_backup.json
# Bun lock file
bun.lock
bun.lock
# Build tools (installed via CI)
tools/
*.zip
# AI agent session data
thoughts/
.impeccable.md
CLAUDE.md
# One-shot scripts (already applied)
fix-ts.js
patch-proxy.js
patch-settings.js
rename-deep.js
test-process.ts
# Legacy CLI launchers
agm.bat
run_cli.bat
# Nix (not used)
flake.nix
flake.lock
# Planning/spec data
.planning/
openspec/
-29
View File
@@ -1,29 +0,0 @@
## Design Context
### Users
Developers and power users who manage multiple AI accounts (Google Gemini, Claude) through a desktop Electron app. They use IDEs like Cursor, OpenCode, and Windsurf. They need to monitor token usage across accounts in real-time to avoid quota exhaustion.
### Brand Personality
- **Professional**: Clean, precise, no clutter
- **Technical**: Data-dense but readable, developer-friendly
- **Efficient**: Information at a glance, minimal clicks
- 3 words: "precise", "technical", "focused"
### Aesthetic Direction
- Dark mode primary (this is a developer tool used for long sessions)
- Deep navy/slate backgrounds, not pure black
- Sharp accent colors for data visualization (subtle blues, teals, amber for warnings)
- High information density without visual noise
- Monospace for numbers/data, clean sans-serif for UI chrome
- No gradients on text, no glassmorphism, no rounded-everything
### Design Principles
1. Data first — charts and numbers are the heroes
2. Every pixel earns its place — no decorative elements
3. Density over whitespace — developers want to see everything
4. Motion only for state changes — no gratuitous animations
5. Consistent with shadcn/ui + Tailwind system already in place
@@ -4,17 +4,20 @@
> Date: 2026-04-22
## Current State
The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basic proxy controls (start/stop, port, protocol, API key, code examples) but leaves significant value on the table. The backend (`ProxyService`, `ProxyModule`) already implements many advanced features with no UI exposure.
## Proposed New Features
### 1. Live Traffic Monitor
- Real-time SSE/WebSocket feed of proxied requests
- Show request method, model, account used, latency, token count
- Filter by status (success, rate-limited, error)
- Inspired by: Request logging already exists in `TrafficLogger`
### 2. IDE Quick-Connect Panel
- One-click copy of ready-to-use configurations for:
- Cursor (`settings.json` snippet)
- OpenCode (`.env` snippet)
@@ -24,6 +27,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- QR code for mobile/quick access
### 3. API Playground
- Built-in Swagger/OpenAPI UI or custom test panel
- Pre-loaded with all supported endpoints:
- `/v1/chat/completions` (OpenAI-compatible)
@@ -33,6 +37,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Show exact curl equivalent for every request
### 4. Health Dashboard
- Real-time metrics cards:
- Requests/minute
- Average latency (p50, p95, p99)
@@ -43,6 +48,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Color-coded status indicators
### 5. Model Availability Status
- Live grid showing which models are currently available
- Per-model indicators:
- Green: available
@@ -52,6 +58,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Collapse/expand by provider (Gemini, Anthropic)
### 6. Error Feed
- Scrollable log of recent errors
- Columns: timestamp, endpoint, model, account, error code, message
- Click to expand full request/response
@@ -59,6 +66,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Export to JSON/CSV
### 7. Connection Visualizer
- Diagram showing: IDE → Proxy → Account Pool → Upstream API
- Animated flow dots representing live requests
- Highlight currently active account
@@ -66,6 +74,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Simple but effective for understanding routing
### 8. One-Click Diagnostics
- "Run Diagnostics" button that performs:
- Check if proxy port is accessible
- Test each configured account
@@ -75,6 +84,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
- Output: green checkmarks or detailed error report with fix suggestions
### 9. Session Stats
- Summary of current session since proxy started:
- Total requests handled
- Total tokens proxied
@@ -86,6 +96,7 @@ The proxy page (`src/routes/proxy.tsx`) is a 724-line monolith that exposes basi
---
## Implementation Notes
- Many of these features require new IPC endpoints in `src/ipc/proxy/router.ts`
- Health metrics likely already exist in `ProxyService` but need exposure
- Traffic logging exists via `TrafficLogger` — just needs UI
@@ -4,6 +4,7 @@
> Date: 2026-04-22
## Current Pain Points
1. **Visual noise**: Gradient backgrounds (`bg-gradient-to-br`) feel dated and clash with the dark, technical aesthetic
2. **Information overload**: All content is visible at once — no progressive disclosure
3. **Code examples dominate**: Large, always-visible code blocks push important controls below the fold
@@ -14,29 +15,35 @@
## Proposed Improvements
### 1. Remove Gradient Backgrounds
- Replace `bg-gradient-to-br from-slate-800 to-slate-900` with solid `bg-card` or subtle borders
- Use elevation (borders, subtle shadows) instead of gradients to create hierarchy
- Align with the rest of the app which uses flat, technical styling
### 2. Tabs-Based Layout
Organize the page into tabs:
- **Status** (default): Service state, quick actions, health metrics
- **Routing**: Model mapping, account scheduling, protocol settings
- **API Docs**: Code examples, endpoint documentation, IDE configs
- **Logs**: Live traffic feed, error history
### 3. Compact Protocol Selector
- Current: large buttons with icons
- Proposed: `Select` dropdown or compact segmented control
- Save horizontal space for more important controls
### 4. Collapsible Code Examples
- Wrap each code block in a `Collapsible` or `Accordion`
- Show only the title + language icon by default
- Expand on click
- Add nested tabs for different languages (cURL, Python, JavaScript, TypeScript)
### 5. Security-Focused API Key Display
- Mask the API key by default (show `sk-••••••••`)
- Reveal on explicit "Show" toggle
- Add "Regenerate" button with confirmation dialog
@@ -44,6 +51,7 @@ Organize the page into tabs:
- Add warning if key is the default/weak key
### 6. Configurable Model Mapping Table
- Instead of a static list, show an editable table:
- Requested model | Resolved model | Provider | Status
- Allow users to add custom mappings
@@ -51,21 +59,25 @@ Organize the page into tabs:
- Validate that resolved models exist in the account pool
### 7. Account Quota Context
- Show the currently selected "primary" account with its quota bar
- If auto-switching is enabled, show "Pool Mode: Active (N accounts)"
- Link to the account pool page for detailed management
### 8. Toast Copy Feedback
- When user clicks "Copy", show a `Toast` or `Sonner` notification
- Current: no feedback — user doesn't know if copy succeeded
- Message: "Copied to clipboard" or "Failed to copy"
### 9. Health Metrics Stub
- Even if backend metrics aren't wired yet, create the UI skeleton
- Show placeholder charts with "Coming soon" badge
- This sets expectations and makes the page feel more complete
### 10. Empty States
- When proxy is stopped, show a clear empty state:
- Icon: `ServerOff` from lucide
- Message: "Proxy is not running"
@@ -75,6 +87,7 @@ Organize the page into tabs:
---
## Design Principles
- **Density over whitespace**: This is a developer tool — show more information per pixel
- **Progressive disclosure**: Hide advanced options behind tabs/accordions
- **Immediate feedback**: Every action should have a visible result (toast, spinner, state change)
@@ -4,13 +4,16 @@
> Date: 2026-04-22
## Discovery
The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many sophisticated features that are **completely invisible** in the current UI. The proxy page only shows: port, protocol, API key, and basic start/stop.
## Hidden Backend Features
### 1. Advanced Routing & Scheduling Controls
**Backend location**: `proxy.service.ts`, `proxy-scheduler.service.ts`
**What's implemented**:
- `scheduler.maxRequestsPerAccount`: Limit requests per account before switching
- `scheduler.maxWaitTime`: Max time to wait for an available account
- `scheduler.retryAttempts`: Number of retries per request
@@ -20,8 +23,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None. These are hardcoded or set via config files only.
### 2. Live Traffic Inspector
**Backend location**: `traffic-logger.service.ts`
**What's implemented**:
- `TrafficLogger` captures every request/response with metadata
- Fields: timestamp, requestId, accountId, model, promptTokens, completionTokens, latency, status, error
- Log rotation and cleanup policies
@@ -30,8 +35,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None. Logs are only written to files.
### 3. Circuit Breaker Dashboard
**Backend location**: `proxy-circuit-breaker.service.ts`
**What's implemented**:
- Per-account circuit breaker state: `CLOSED`, `OPEN`, `HALF_OPEN`
- Failure threshold and recovery timeout
- When open: requests fail fast without hitting the API
@@ -40,8 +47,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None. Circuit breaker state is internal only.
### 4. Parity Testing Sandbox
**Backend location**: `proxy-parity.service.ts`
**What's implemented**:
- Send identical requests to multiple models simultaneously
- Compare responses for consistency
- Detect model drift or API changes
@@ -50,8 +59,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None. Only accessible via manual API calls or scripts.
### 5. Custom Model Mapping Table
**Backend location**: `proxy-model-mapper.service.ts`
**What's implemented**:
- Dynamic model resolution: `claude-opus-4` → `gemini-3-flash`
- User-defined mappings stored in database
- Regex-based model matching
@@ -60,8 +71,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: A static, read-only list is shown. No editing capability.
### 6. Upstream Proxy Configuration
**Backend location**: `proxy-upstream.service.ts`
**What's implemented**:
- `upstreamProxy.url`: Route all traffic through another proxy
- `upstreamProxy.auth`: Basic auth or bearer token for upstream
- `upstreamProxy.bypassList`: Domains to bypass
@@ -69,8 +82,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None. Set via environment variables only.
### 7. IDE Integration Quick-Setup
**Backend location**: `proxy-config.service.ts` — `generateIdeConfig()`
**What's implemented**:
- Generates ready-to-paste configs for:
- Cursor (`settings.json`)
- VS Code + Cline/Roo Code
@@ -82,8 +97,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: Code examples are generic curl snippets. No IDE-specific configs.
### 8. Health & Performance Metrics
**Backend location**: `proxy-metrics.service.ts`
**What's implemented**:
- Request latency histogram (p50, p95, p99)
- Requests per minute counter
- Error rate by status code
@@ -94,8 +111,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None. Metrics are only exposed via a `/metrics` endpoint for Prometheus.
### 9. Request Replay & Debug Tool
**Backend location**: `proxy-replay.service.ts`
**What's implemented**:
- Store recent requests (last 100 by default)
- Replay any request with one click
- Modify headers/body before replay
@@ -105,8 +124,10 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
**UI exposure**: None.
### 10. Global Timeout & Max Wait Controls
**Backend location**: `proxy-config.service.ts`
**What's implemented**:
- `requestTimeout`: Global timeout for all upstream requests
- `connectTimeout`: TCP connection timeout
- `maxWaitTime`: Max time to wait for an available account
@@ -117,7 +138,9 @@ The backend (`ProxyService`, `ProxyController`, `ProxyModule`) implements many s
---
## Recommendation
Create a new "Advanced" tab on the proxy page that exposes these features progressively:
1. **Phase 1**: Read-only views (circuit breaker status, health metrics, traffic logs)
2. **Phase 2**: Interactive controls (model mapping editor, scheduler settings)
3. **Phase 3**: Power-user tools (replay, parity testing, upstream proxy config)
@@ -4,7 +4,9 @@
> Date: 2026-04-22
## Current State
`src/routes/proxy.tsx` is a **724-line monolith** containing:
- Route definition
- State management (useState, useEffect, useMemo)
- Data fetching (useQuery equivalents for proxy config, local IPs)
@@ -14,6 +16,7 @@
- Constants and helper functions
## Target Architecture
Split into **7+ files** following the project's conventions:
```
@@ -39,7 +42,9 @@ src/
## File Breakdown
### 1. `src/hooks/useProxyConfig.ts`
**Responsibility**: Fetch and mutate proxy configuration
```typescript
export function useProxyConfig() {
// Fetches from ipc.client.proxy.getConfig()
@@ -49,7 +54,9 @@ export function useProxyConfig() {
```
### 2. `src/hooks/useLocalIps.ts`
**Responsibility**: Get local IP addresses for the access banner
```typescript
export function useLocalIps() {
// Fetches from ipc.client.system.getLocalIps()
@@ -58,7 +65,9 @@ export function useLocalIps() {
```
### 3. `src/hooks/useProxyExamples.ts`
**Responsibility**: Generate code examples based on current config
```typescript
export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
// Returns memoized examples for curl, Python, Node.js
@@ -67,7 +76,9 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
```
### 4. `src/components/proxy/ProxyServiceCard.tsx`
**Responsibility**: The main service control card
- Start/stop toggle
- Port input
- Protocol selector
@@ -75,26 +86,34 @@ export function useProxyExamples(config: ProxyConfig, localIps: string[]) {
- Status indicator
### 5. `src/components/proxy/ProxyModelMappingCard.tsx`
**Responsibility**: Model mapping display/editor
- Read-only list (current state)
- Future: editable table with add/remove
- Show provider badges
### 6. `src/components/proxy/ProxyUsageExamplesCard.tsx`
**Responsibility**: Code examples section
- Collapsible/accordion layout
- Tabs for different languages
- Copy-to-clipboard buttons
- Syntax highlighting (if available)
### 7. `src/components/proxy/ProxyLocalAccessBanner.tsx`
**Responsibility**: "Access your proxy locally" banner
- Show local IPs
- QR code (future)
- Copy URL buttons
### 8. `src/constants/proxy.ts`
**Responsibility**: Constants
```typescript
export const DEFAULT_PROXY_PORT = 3000;
export const PROTOCOLS = ['http', 'https'] as const;
@@ -102,7 +121,9 @@ export const EXAMPLE_MODELS = ['gpt-4o', 'claude-3-opus', 'gemini-pro'];
```
### 9. `src/utils/proxy.ts`
**Responsibility**: Pure helper functions
```typescript
export function buildProxyUrl(protocol: string, ip: string, port: number): string;
export function maskApiKey(key: string): string;
@@ -141,6 +162,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
```
## Migration Steps
1. Create new files (hooks, components, constants, utils)
2. Move code from `proxy.tsx` to appropriate files
3. Update imports in `proxy.tsx`
@@ -149,6 +171,7 @@ export const Route = createFileRoute('/proxy')({ component: ProxyPage });
6. Verify no regression in functionality
## Benefits
- **Testability**: Each hook and component can be unit tested in isolation
- **Reusability**: `useLocalIps` can be used in other pages (e.g., settings)
- **Maintainability**: 50-line route file vs. 724-line monolith
@@ -4,6 +4,7 @@
> Date: 2026-04-22
## Severity Legend
- 🔴 **Critical**: Bugs or crashes
- 🟡 **High**: Performance or reliability issues
- 🟢 **Medium**: Maintainability or best practice violations
@@ -11,21 +12,26 @@
## Issues Found
### 1. 🔴 State Duplication Between Proxy Config and Local State
**Location**: Lines ~120-180
**Issue**: The component maintains both `proxyConfig` (from IPC) and local `useState` copies for `port`, `protocol`, `apiKey`. These can drift out of sync if the backend config changes externally.
**Fix**: Use a single source of truth. Either:
- Derive local state from `proxyConfig` with `useEffect` sync
- Or use `useQuery` with `staleTime: 0` and no local cache
### 2. 🟡 Race Condition in Start/Stop Toggle
**Location**: Lines ~200-250
**Issue**: If user clicks start/stop rapidly, multiple IPC calls fire in parallel. The UI shows intermediate states that may not reflect the actual backend state.
**Fix**: Disable the toggle button while a transition is in progress. Add an `isTransitioning` flag.
### 3. 🟢 Clipboard Error Handling
**Location**: Lines ~300-320
**Issue**: `navigator.clipboard.writeText()` can fail (permission denied, not in secure context). The code has no try/catch.
**Fix**:
```typescript
async function copyToClipboard(text: string) {
try {
@@ -38,18 +44,22 @@ async function copyToClipboard(text: string) {
```
### 4. 🟢 Unstable Callbacks Causing Re-renders
**Location**: Throughout
**Issue**: Event handlers are defined inline in JSX, causing child components to re-render unnecessarily.
**Fix**: Wrap handlers in `useCallback`:
```typescript
const handleStart = useCallback(() => startProxy(), [startProxy]);
const handleStop = useCallback(() => stopProxy(), [stopProxy]);
```
### 5. 🟡 Memory Leak in setTimeout
**Location**: Lines ~400-420
**Issue**: A `setTimeout` is used for a "copy feedback" animation but is never cleared on unmount.
**Fix**: Use `useEffect` cleanup:
```typescript
useEffect(() => {
const timer = setTimeout(() => setCopied(false), 2000);
@@ -58,24 +68,29 @@ useEffect(() => {
```
### 6. 🟢 Missing Loading States for Mutations
**Location**: Lines ~200-250
**Issue**: When starting/stopping the proxy, there's no loading spinner or disabled state on the button.
**Fix**: Add `isPending` state to the mutation hook and disable the button + show spinner while pending.
### 7. 🟢 Hardcoded i18n Strings
**Location**: Throughout
**Issue**: Many UI strings are hardcoded in English instead of using `useTranslation()`:
- "Proxy Service"
- "Start Proxy"
- "Stop Proxy"
- "Copy"
- "Copied!"
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
**Fix**: Extract all strings to `src/localization/i18n.ts` and use `t('proxy.startProxy')`, etc.
### 8. 🟢 Magic Numbers
**Location**: Lines ~450-500
**Issue**: Port validation uses hardcoded `3000`, `65535` without constants.
**Fix**:
```typescript
const MIN_PORT = 1024;
const MAX_PORT = 65535;
@@ -83,9 +98,11 @@ const DEFAULT_PORT = 3000;
```
### 9. 🟢 Unsafe Type Cast
**Location**: Lines ~550-600
**Issue**: `event.target.value` is cast to `Protocol` without validation.
**Fix**:
```typescript
const protocol = event.target.value;
if (!PROTOCOLS.includes(protocol)) return;
@@ -93,22 +110,27 @@ setProtocol(protocol as Protocol);
```
### 10. 🟡 Dangerous Parsing of Local IPs
**Location**: Lines ~650-700
**Issue**: `localIps` is parsed from a shell command output using string splitting that could break on unexpected formats.
**Fix**: Use a robust parser or, better, expose `os.networkInterfaces()` via a safe IPC endpoint.
### 11. 🟢 Unnecessary Re-renders from Inline Objects
**Location**: Lines ~350-380
**Issue**: Inline style objects and arrays are recreated on every render, breaking `React.memo` optimizations in child components.
**Fix**: Extract static styles outside the component or memoize:
```typescript
const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
```
### 12. 🟢 No Validation on Port Input
**Location**: Lines ~150-170
**Issue**: User can type any value into the port field, including non-numbers, negatives, or out-of-range values.
**Fix**: Add input validation:
```typescript
<input
type="number"
@@ -125,6 +147,7 @@ const cardStyle = useMemo(() => ({ padding: '1.5rem' }), []);
---
## Testing Recommendations
- Add unit tests for `useProxyConfig` hook (mock IPC client)
- Test race conditions: rapid start/stop clicks
- Test clipboard failure path
@@ -6,41 +6,45 @@
## File Map
### Frontend
| File | Lines | Role |
|------|-------|------|
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
| File | Lines | Role |
| -------------------------------------------- | ----- | ---------------------------------------- |
| `src/routes/proxy.tsx` | 724 | Main proxy page (monolith) |
| `src/components/ModelVisibilitySettings.tsx` | ~200 | Model visibility toggle grid |
| `src/components/AccountProxySettings.tsx` | ~180 | Per-account proxy configuration |
| `src/hooks/useCloudAccounts.ts` | ~50 | Fetches cloud accounts for proxy routing |
### Backend (NestJS)
| File | Role |
|------|------|
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
| File | Role |
| ----------------------------------------------------------- | ------------------------------------------- |
| `src/server/modules/proxy/proxy.module.ts` | NestJS module definition |
| `src/server/modules/proxy/proxy.service.ts` | 1883 lines — core proxy logic, all handlers |
| `src/server/modules/proxy/proxy.controller.ts` | HTTP endpoints for proxy control |
| `src/server/modules/proxy/proxy-scheduler.service.ts` | Account rotation & scheduling |
| `src/server/modules/proxy/proxy-circuit-breaker.service.ts` | Per-account circuit breaker |
| `src/server/modules/proxy/traffic-logger.service.ts` | Request/response logging |
| `src/server/modules/proxy/proxy-metrics.service.ts` | Prometheus metrics |
| `src/server/modules/proxy/proxy-model-mapper.service.ts` | Model name resolution |
| `src/server/modules/proxy/proxy-parity.service.ts` | Parity testing |
| `src/server/modules/proxy/proxy-replay.service.ts` | Request replay |
| `src/server/modules/proxy/proxy-config.service.ts` | Config management |
| `src/server/modules/proxy/proxy-upstream.service.ts` | Upstream proxy support |
### IPC Layer
| File | Role |
|------|------|
| `src/ipc/router.ts` | Main IPC router composition |
| File | Role |
| ------------------------- | ---------------------------- |
| `src/ipc/router.ts` | Main IPC router composition |
| `src/ipc/proxy/router.ts` | Proxy-specific IPC endpoints |
| `src/ipc/manager.ts` | IPC client initialization |
| `src/ipc/manager.ts` | IPC client initialization |
### Types & Schema
| File | Role |
|------|------|
| File | Role |
| --------------------- | ------------------------------------------------ |
| `src/types/config.ts` | `ProxyConfig` Zod schema — reveals hidden fields |
| `src/types/proxy.ts` | Request/response types |
| `src/types/proxy.ts` | Request/response types |
## Hidden ProxyConfig Fields
@@ -124,6 +128,7 @@ interface ProxyConfig {
```
## Key Observations
1. **Backend is significantly more capable than the UI suggests** — 12 services vs. 1 page
2. **IPC layer is thin** — most backend services are not exposed via IPC, only HTTP
3. **ProxyConfig schema is the source of truth** — it documents everything the backend can do
@@ -131,6 +136,7 @@ interface ProxyConfig {
5. **Separation of concerns is good on backend**, terrible on frontend (724-line monolith)
## Recommended Architecture Changes
1. Extend `src/ipc/proxy/router.ts` to expose all `ProxyConfig` fields
2. Add IPC endpoints for:
- `proxy.getTrafficLogs()`
+1
View File
@@ -10,3 +10,4 @@ openspec/
.agent/
.claude/
.codex
pnpm-lock.yaml
-48
View File
@@ -1,48 +0,0 @@
{
"branches": ["main"],
"plugins": [
["@semantic-release/commit-analyzer", {
"preset": "conventionalcommits",
"releaseRules": [
{ "type": "feat", "release": "minor" },
{ "type": "fix", "release": "patch" },
{ "type": "perf", "release": "patch" },
{ "type": "refactor", "release": "patch" },
{ "type": "docs", "release": false },
{ "type": "style", "release": false },
{ "type": "chore", "release": false },
{ "type": "ci", "release": false },
{ "type": "test", "release": false },
{ "breaking": true, "release": "major" }
]
}],
["@semantic-release/release-notes-generator", {
"preset": "conventionalcommits",
"presetConfig": {
"types": [
{ "type": "feat", "section": "🚀 Features" },
{ "type": "fix", "section": "🐛 Bug Fixes" },
{ "type": "perf", "section": "⚡ Performance" },
{ "type": "refactor", "section": "♻️ Refactors" },
{ "type": "docs", "section": "📚 Documentation", "hidden": true },
{ "type": "chore", "section": "🧰 Maintenance", "hidden": true },
{ "type": "ci", "section": "🔧 CI/CD", "hidden": true }
]
}
}],
["@semantic-release/changelog", {
"changelogFile": "CHANGELOG.md"
}],
["@semantic-release/npm", {
"npmPublish": false
}],
["@semantic-release/git", {
"assets": ["package.json", "package-lock.json", "CHANGELOG.md"],
"message": "chore(release): ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}"
}],
["@semantic-release/github", {
"successComment": false,
"failTitle": false
}]
]
}
+20 -20
View File
@@ -12,7 +12,7 @@ This document is a user-level guide for AI assistants working in this repository
- **Runtime**: Node.js (Electron environment)
- **Node**: Recommended Node.js 20+
- **Package manager**: `npm` (this project includes `package-lock.json`; use npm only)
- **Package manager**: `pnpm` (this project includes `pnpm-lock.yaml`; use pnpm only)
- **Build tools**: Electron Forge + Vite
- **Terminal**: Windows (PowerShell) / VSCode MCP tools can be used safely
@@ -75,32 +75,32 @@ This document is a user-level guide for AI assistants working in this repository
## 📦 Common Scripts
Use `npm` for all commands:
Use `pnpm` for all commands:
- **Development (Dev)**:
- `npm start` - Start Electron dev environment (Electron Forge)
- `npm run lint` - Run ESLint checks
- `npm run format` - Run Prettier check
- `npm run format:write` - Auto-format with Prettier
- `npm run type-check` - Run TypeScript type check
- `pnpm start` - Start Electron dev environment (Electron Forge)
- `pnpm run lint` - Run ESLint checks
- `pnpm run format` - Run Prettier check
- `pnpm run format:write` - Auto-format with Prettier
- `pnpm run type-check` - Run TypeScript type check
- **Build**:
- `npm run package` - Package app (application bundle only)
- `npm run make` - Build and generate distributable installers
- `npm run publish` - Publish app
- `pnpm run package` - Package app (application bundle only)
- `pnpm run make` - Build and generate distributable installers
- `pnpm run publish` - Publish app
- **Testing**:
- `npm test` - Run Vitest tests
- `npm run test:watch` - Run Vitest in watch mode
- `npm run test:unit` - Same as above for unit-focused runs
- `npm run test:e2e` - Run Playwright E2E tests
- `npm run test:all` - Run all tests
- `pnpm test` - Run Vitest tests
- `pnpm run test:watch` - Run Vitest in watch mode
- `pnpm run test:unit` - Same as above for unit-focused runs
- `pnpm run test:e2e` - Run Playwright E2E tests
- `pnpm run test:all` - Run all tests
### Running a Single Test
- Unit test: `npm run test:unit path/to/test.test.ts`
- E2E test: `npm run test:e2e path/to/test.spec.ts`
- Type check: `npm run type-check`
- Unit test: `pnpm run test:unit path/to/test.test.ts`
- E2E test: `pnpm run test:e2e path/to/test.spec.ts`
- Type check: `pnpm run type-check`
## 🧪 Development Notes
@@ -171,7 +171,7 @@ export const Component: React.FC<ComponentProps> = ({ prop1 }) => {
};
```
> Before commit, run `npm run lint` and `npm run format`.
> Before commit, run `pnpm run lint` and `pnpm run format`.
## 📝 Terminal Output and References
@@ -330,7 +330,7 @@ Summary / output:
Packages and scripts:
- `npm install` (or `npm i`)
- `pnpm install` (or `pnpm i`)
Shell:
+91 -1
View File
@@ -1,3 +1,93 @@
<a name="readme-top"></a>
# Changelog
## [0.15.5](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.4...v0.15.5) (2026-10-01)
### 🐛 Bug Fixes
* parse complete retry durations and preserve local IDE configuration ([b193bee](https://github.com/evandrodevbr/GeminiNexus/commit/b193bee03f1aaea5f7173e53ec6cdb787717067c))
### ✅ Tests
* mock OS exports consistently and verify LAN discovery ([fa39fb3](https://github.com/evandrodevbr/GeminiNexus/commit/fa39fb34fef662c11cd8780b562fa8008b982823))
## [0.15.4](https://github.com/evandrodevbr/GeminiNexus/compare/v0.15.3...v0.15.4) (2026-09-14)
### 📝 Documentation
* establish dual license (CC-BY-NC-SA + MIT) and correct attribution ([42466c2](https://github.com/evandrodevbr/GeminiNexus/commit/42466c274b36b5dde2d7d33c9016b2fab05cf551))
* refine READMEs and highlight Windows SmartScreen warning ([0dbae81](https://github.com/evandrodevbr/GeminiNexus/commit/0dbae81353c162b0b95d566493164ea6c02e2fa2))
* rewrite the README with verified behavior and honest limitations ([acb8508](https://github.com/evandrodevbr/GeminiNexus/commit/acb8508bff2f6e32691f394d21d7c8193608cd5e))
### 💄 Styles
* clear the lint and prettier findings so the gates pass ([8ad3eef](https://github.com/evandrodevbr/GeminiNexus/commit/8ad3eef53d88d049a3a25cb54df590073a67b75b))
### 🔧 Continuous Integration
* upload release assets sequentially with retry ([b13f06c](https://github.com/evandrodevbr/GeminiNexus/commit/b13f06c0a3538ff962eae2f6fc07cfc81281bf02))
## [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 +398,4 @@
- Secure credential storage (AES-256-GCM)
- IDE synchronization
- Dark mode support
- System tray integration
- System tray integration
-1
View File
@@ -1 +0,0 @@
Always refer to `@AGENTS.md`.
+22 -22
View File
@@ -63,17 +63,17 @@ npm start
### Available Scripts
| Command | Description |
|---------|-------------|
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
| `npm run lint` | Run ESLint |
| `npm run format` | Check formatting with Prettier |
| `npm run format:write` | Auto-format code with Prettier |
| `npm run type-check` | Run TypeScript type check |
| `npm test` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests (unit + E2E) |
| `npm run make` | Build production installers |
| Command | Description |
| ---------------------- | ------------------------------------------------------- |
| `npm start` | Start the app in development mode (Electron + Vite HMR) |
| `npm run lint` | Run ESLint |
| `npm run format` | Check formatting with Prettier |
| `npm run format:write` | Auto-format code with Prettier |
| `npm run type-check` | Run TypeScript type check |
| `npm test` | Run unit tests with Vitest |
| `npm run test:e2e` | Run E2E tests with Playwright |
| `npm run test:all` | Run all tests (unit + E2E) |
| `npm run make` | Build production installers |
### Project Structure
@@ -135,17 +135,17 @@ We follow [Conventional Commits](https://www.conventionalcommits.org/). Commits
### Types
| Type | Description | Release |
|------|-------------|---------|
| `feat` | A new feature | **minor** |
| `fix` | A bug fix | **patch** |
| `perf` | Performance improvement | **patch** |
| `refactor` | Code change (no bug fix or feature) | **patch** |
| `docs` | Documentation only | no release |
| `style` | Formatting, whitespace | no release |
| `test` | Adding or fixing tests | no release |
| `chore` | Build process, tooling | no release |
| `ci` | CI/CD changes | no release |
| Type | Description | Release |
| ---------- | ----------------------------------- | ---------- |
| `feat` | A new feature | **minor** |
| `fix` | A bug fix | **patch** |
| `perf` | Performance improvement | **patch** |
| `refactor` | Code change (no bug fix or feature) | **patch** |
| `docs` | Documentation only | no release |
| `style` | Formatting, whitespace | no release |
| `test` | Adding or fixing tests | no release |
| `chore` | Build process, tooling | no release |
| `ci` | CI/CD changes | no release |
### Breaking Changes
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025-present evandrodevbr
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+150 -65
View File
@@ -23,27 +23,48 @@
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="Licencia" />
</a>
<img src="https://img.shields.io/badge/plataforma-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="Plataforma" />
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
</p>
> [!IMPORTANT]
> **Usuarios de Windows:** Si ves una advertencia de SmartScreen **"Windows protegió su PC"** durante la instalación, haz clic en **"Más información"** y luego en **"Ejecutar de todas formas"**. Esta es una advertencia de seguridad esperada para aplicaciones nuevas no firmadas.
---
## 📖 Tabla de Contenidos
- [✨ ¿Por qué Gemini Nexus?](#-por-qué-gemini-nexus)
- [🎯 Características](#-características)
- [📸 Capturas de Pantalla](#-capturas-de-pantalla)
- [⚡ Instalación e Inicio Rápido](#-instalación-e-inicio-rápido)
- [🛠️ Stack Tecnológico](#️-stack-tecnológico)
- [💻 Desarrollo](#-desarrollo)
- [❓ Preguntas Frecuentes](#-preguntas-frecuentes)
- [🤝 Contribuir](#-contribuir)
- [📄 Licencia](#-licencia)
---
## ✨ ¿Por qué Gemini Nexus?
Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con estos problemas?
- 😫 La cuota de una sola cuenta se agota rápido, requiriendo cambios manuales frecuentes
- 🔄 Gestionar múltiples cuentas de Google/Claude es tedioso
- 📊 No sabes cuántos tokens has consumido o cuánta cuota queda
- 🔌 Necesitas un proxy local confiable que hable los protocolos OpenAI/Anthropic
- 😫 **Límites de cuota:** La cuota de una sola cuenta se agota rápido, requiriendo cambios manuales frecuentes.
- 🔄 **Gestión de cuentas:** Gestionar múltiples cuentas de Google/Claude es tedioso.
- 📊 **Uso a ciegas:** No sabes cuántos tokens has consumido o cuánta cuota queda.
- 🔌 **Problemas de integración:** Necesitas un proxy local confiable que hable los protocolos OpenAI/Anthropic de forma nativa.
- 🔍 **Falta de transparencia:** No puedes ver qué se envía o recibe exactamente a través del proxy.
**Gemini Nexus** resuelve todo esto. Es una aplicación de escritorio profesional en Electron que actúa como gateway inteligente entre tus herramientas de desarrollo y Google Gemini / Claude AI:
**Gemini Nexus** resuelve todo esto. Es una aplicación de escritorio profesional en Electron que actúa como gateway inteligente entre tus herramientas de desarrollo y Google Gemini / Claude AI.
- ✅ **Pool de Cuentas Ilimitado** — Agrega cualquier cantidad de cuentas Google Gemini y Claude
- ✅ **Cambio Automático Inteligente** — Rota automáticamente a la siguiente cuenta disponible cuando la cuota es baja
- ✅ **Analytics de Uso en Tiempo Real** — Dashboard nivel SaaS con gráficos de área, indicadores de tendencia y distribución por modelo
- ✅ **Observabilidad Completa del Proxy** — Monitor de tráfico en vivo, replay de solicitudes e inspector de capacidades de modelos
- ✅ **Compatible con OpenAI y Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode y cualquier herramienta compatible
- ✅ **Seguro por Defecto** — Cifrado AES-256-GCM con gestión nativa de credenciales del SO
### Propuesta de Valor
- ✅ **Pool de Cuentas Ilimitado** — Agrega cualquier cantidad de cuentas Google Gemini y Claude.
- ✅ **Cambio Automático Inteligente** — Rota automáticamente a la siguiente cuenta disponible cuando la cuota es baja o hay rate limit.
- ✅ **Analytics de Uso en Tiempo Real** — Dashboard nivel SaaS con gráficos de área, indicadores de tendencia y distribución por modelo.
- ✅ **Observabilidad Completa del Proxy** — Monitor de tráfico en vivo, replay de solicitudes e inspector de capacidades de modelos.
- ✅ **Compatible con OpenAI y Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode y cualquier herramienta compatible con OpenAI.
- ✅ **Seguro por Defecto** — Cifrado AES-256-GCM con gestión nativa de credenciales del sistema operativo.
---
@@ -79,7 +100,7 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
<ul>
<li>Endpoint compatible con OpenAI <code>/v1/chat/completions</code></li>
<li>Endpoint compatible con Anthropic <code>/v1/messages</code></li>
<li>Soporte completo de streaming SSE</li>
<li>Soporte completo de streaming SSE (probado con Cursor, Windsurf, OpenCode)</li>
<li>Mapeo de modelos (ej: <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
<li>Puerto, timeout y API key configurables</li>
<li>Control de visibilidad de modelos</li>
@@ -99,17 +120,17 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
<td width="50%">
<h3>🔍 Observabilidad del Proxy (Avanzado)</h3>
<ul>
<li><strong>Monitor de Tráfico</strong> — Log en vivo de solicitudes/respuestas</li>
<li><strong>Monitor de Tráfico</strong> — Log en vivo de solicitudes/respuestas con latencia y modelo</li>
<li><strong>Replay de Solicitudes</strong> — Reproduce cualquier solicitud para debugging</li>
<li><strong>Capacidades de Modelos</strong> — Inspecciona visión, thinking, streaming, modo JSON</li>
<li><strong>Herramientas para Devs</strong> — Generación de código cURL y Python</li>
<li><strong>Capacidades de Modelos</strong> — Inspecciona soporte de visión, thinking, streaming, modo JSON</li>
<li><strong>Herramientas para Devs</strong> — Generación de código cURL y Python, copia con un clic</li>
</ul>
</td>
<td width="50%">
<h3>🔐 Seguridad y Cifrado</h3>
<ul>
<li>Cifrado AES-256-GCM para todos los datos sensibles</li>
<li>Integración con gestor de credenciales nativo del SO</li>
<li>Integración con gestor de credenciales nativo del SO (Keytar + SafeStorage)</li>
<li>Migración automática de datos legacy en texto plano</li>
<li>Almacenamiento cifrado de tokens y cuotas por cuenta</li>
</ul>
@@ -119,8 +140,9 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
<td width="50%">
<h3>⚙️ Configuración y Personalización</h3>
<ul>
<li>Tema Oscuro / Claro / Sistema</li>
<li>Soporte de temas Oscuro / Claro / Sistema</li>
<li>Multi-idioma: English & Português (Brasil)</li>
<li>Override de proxy URL por cuenta</li>
<li>Toggles de visibilidad de modelos</li>
<li>Acceso al directorio de logs</li>
</ul>
@@ -130,8 +152,9 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
<ul>
<li>App nativa Electron con integración en bandeja del sistema</li>
<li>Sidebar colapsable con estado persistente</li>
<li>Layouts responsivos</li>
<li>Barra de estado con indicadores en vivo</li>
<li>Layouts responsivos en todas las vistas</li>
<li>Barra de estado con indicadores en vivo de proxy y conexión</li>
<li>Error boundaries con fallbacks amigables al usuario</li>
</ul>
</td>
</tr>
@@ -165,88 +188,150 @@ Al usar IDEs y herramientas de programación con IA, ¿te has encontrado con est
---
## ⚡ Inicio Rápido
## ⚡ Instalación e Inicio Rápido
### Requisitos Previos
### 📦 Descarga
- **Node.js** v20 o superior
- **npm**
Puedes descargar los binarios precompilados más recientes para Windows, macOS y Linux desde nuestra [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases).
### Compilar desde el Código Fuente
_(Consulta la advertencia en la parte superior de esta página si usas Windows y te encuentras con el aviso de SmartScreen)._
```bash
git clone https://github.com/evandrodevbr/GeminiNexus.git
cd GeminiNexus
npm install
npm start
### 🔌 Uso con IDEs de IA
# Compilar para producción
npm run make
```
### Uso con IDEs de IA
Con la aplicación en ejecución y al menos una cuenta agregada, configura tu IDE preferido (Cursor, Windsurf, OpenCode, etc.):
```plaintext
API Base URL: http://localhost:10100/v1
API Base URL: http://localhost:8045/v1
API Key: (copiar de la página Proxy en la app)
Model: gemini-3-flash
Model: gemini-3-flash (o cualquier modelo mapeado)
```
### 🛠️ Compilar desde el Código Fuente
#### Requisitos Previos
- **Node.js** v20 o superior
- **npm** (este proyecto usa `package-lock.json`)
#### Pasos
```bash
# Clonar el repositorio
git clone https://github.com/evandrodevbr/GeminiNexus.git
cd GeminiNexus
# Instalar dependencias
npm install
# Iniciar en modo desarrollo
npm start
# Compilar para producción (ej: instalador de Windows)
npm run make
```
---
## 🛠️ 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 |
---
## 💻 Desarrollo
```bash
# Iniciar entorno de dev (Electron + Vite HMR)
npm start
# Ejecutar linting
npm run lint
# Verificación de tipos
npm run type-check
# Formatear código
npm run format:write
# Ejecutar pruebas
npm test
```
---
## ❓ Preguntas Frecuentes
<details>
<summary><b>P: ¿La app no inicia?</b></summary>
<summary><b>P: ¿Aviso de Windows SmartScreen ("Windows protegió su PC") durante la instalación?</b></summary>
1. Asegúrate de que las dependencias están instaladas: `npm install`
2. Node.js versión >= 20
3. Intenta eliminar `node_modules` y reinstalar
Sí, este es un aviso común para nuevas aplicaciones no firmadas. Haz clic en **"Más información"** y luego en **"Ejecutar de todas formas"**. Consulta la advertencia en la parte superior del README para más detalles.
</details>
<details>
<summary><b>P: ¿El IDE no conecta al proxy?</b></summary>
1. Asegúrate de que el proxy está corriendo (indicador verde en la barra de estado)
2. Confirma que el puerto coincide (por defecto `10100`)
3. Copia la API key de la página Proxy
4. Asegúrate de tener al menos una cuenta activa
<summary><b>P: ¿La app no inicia al compilar desde el código fuente?</b></summary>
1. Asegúrate de que todas las dependencias están instaladas: `npm install`.
2. Verifica que tu versión de Node.js sea >= 20.
3. Intenta eliminar `node_modules` y reinstalar.
4. En Windows, asegúrate de que el WiX Toolset esté disponible para el comando `npm run make`.
</details>
<details>
<summary><b>P: ¿El inicio de sesión de la cuenta falló?</b></summary>
1. Asegúrate de que tu conexión a la red funcione correctamente.
2. Intenta borrar los datos de la app y volver a iniciar sesión.
3. Comprueba si la cuenta está restringida por Google/Claude.
</details>
<details>
<summary><b>P: ¿El IDE no se puede conectar al proxy?</b></summary>
1. Asegúrate de que el proxy esté funcionando (indicador verde en la barra de estado).
2. Confirma que el puerto coincide con tu configuración del IDE (por defecto `8045`).
3. Copia la API key directamente desde la página Proxy y pégala en la configuración de tu IDE.
4. Asegúrate de tener al menos una cuenta activa en la página Cuentas.
</details>
---
## 🤝 Contribuir
¡Las contribuciones son bienvenidas! Por favor lee `CONTRIBUTING.md` para más detalles.
---
## 📄 Licencia
[CC BY-NC-SA 4.0](LICENSE)
Este proyecto utiliza una estructura de licencia dual:
---
- **Código original** de [AntigravityManager](https://github.com/Draculabo/AntigravityManager) por [Draculabo](https://github.com/Draculabo): Licenciado bajo [CC BY-NC-SA 4.0](LICENSE).
- **Todo el código nuevo, funcionalidades y arquitectura** por [evandrodevbr](https://github.com/evandrodevbr): Licenciado bajo la [Licencia MIT](LICENSE-MIT).
## ⚠️ Aviso Legal
> [!WARNING]
> **Solo con Fines Educativos**
>
> Este proyecto está destinado exclusivamente a fines educativos y de investigación. Se proporciona "tal cual" sin ninguna garantía. **El uso comercial está estrictamente prohibido.**
> 🔄 **Aviso de Migración:** Este proyecto está siendo migrado activamente a una licencia completamente open-source (MIT). A medida que el código original restante se reescribe progresivamente, todo el proyecto hará la transición a MIT. Estamos comprometidos a hacer Gemini Nexus 100% open-source.
---
## 🙏 Créditos
**Gemini Nexus** es una versión significativamente mejorada y expandida del proyecto original [AntigravityManager](https://github.com/Draculabo/AntigravityManager) creado por [Draculabo](https://github.com/Draculabo). Toda la arquitectura fundamental y diseño fueron realizados por el autor original.
Este proyecto fue originalmente un fork de [AntigravityManager](https://github.com/Draculabo/AntigravityManager) creado por [Draculabo](https://github.com/Draculabo). El concepto inicial del proxy y la estructura base de Electron se basaron en su trabajo.
Desde el fork, **la gran mayoría del código ha sido reescrito, rediseñado y expandido** por [evandrodevbr](https://github.com/evandrodevbr), incluyendo pero no limitado a:
- Rediseño completo de UI/UX (navegación, cuentas, analíticas de uso, gestión del proxy)
- Dashboard de Analíticas de Uso con seguimiento de costos, promedios de tokens e integración con OpenRouter
- Monitor de tráfico con inspección de tokens/costos por solicitud
- Aislamiento de entorno (dev/prod), pipeline CI/CD e infraestructura de pruebas
- Configuración rápida para IDEs (OpenCode, Cursor, VS Code, Claude Code)
- Configuración personalizada de precios de modelos
- Toda la documentación e internacionalización (EN, PT-BR, ES, ZH-CN)
+186 -283
View File
@@ -5,7 +5,7 @@
<h1 align="center">Gemini Nexus</h1>
<p align="center">
<strong>🚀 Professional multi-account AI gateway for Google Gemini & Claude</strong>
<strong>Desktop gateway that pools several Google Gemini and Claude accounts behind a single local OpenAI- and Anthropic-compatible API.</strong>
</p>
<p align="center">
@@ -13,336 +13,239 @@
</p>
<p align="center">
<a href="https://github.com/evandrodevbr/GeminiNexus/releases">
<img src="https://img.shields.io/github/v/release/evandrodevbr/GeminiNexus?style=flat-square" alt="Release" />
</a>
<a href="https://github.com/evandrodevbr/GeminiNexus/releases">
<img src="https://img.shields.io/github/downloads/evandrodevbr/GeminiNexus/total?style=flat-square&color=blue" alt="Downloads" />
</a>
<a href="https://github.com/evandrodevbr/GeminiNexus/blob/main/LICENSE">
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="License" />
</a>
<a href="https://github.com/evandrodevbr/GeminiNexus/releases"><img src="https://img.shields.io/github/v/release/evandrodevbr/GeminiNexus?style=flat-square" alt="Release" /></a>
<a href="https://github.com/evandrodevbr/GeminiNexus/actions/workflows/testing.yaml"><img src="https://github.com/evandrodevbr/GeminiNexus/actions/workflows/testing.yaml/badge.svg" alt="Tests" /></a>
<img src="https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="Platform" />
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
<img src="https://img.shields.io/badge/electron-41-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron 41" />
<img src="https://img.shields.io/badge/react-19-61DAFB?style=flat-square&logo=react&logoColor=white" alt="React 19" />
<img src="https://img.shields.io/badge/node-%3E%3D20.19-5FA04E?style=flat-square&logo=nodedotjs&logoColor=white" alt="Node 20.19+" />
<img src="https://img.shields.io/badge/license-MIT%20%2B%20CC%20BY--NC--SA%204.0-blue?style=flat-square" alt="License" />
</p>
---
> **Windows users:** the installers are not code-signed, so SmartScreen may show "Windows protected your PC". Choose "More info" and then "Run anyway"; this is the expected prompt for an unsigned application.
## 📖 Table of Contents
## About
- [Why Gemini Nexus?](#-why-gemini-nexus)
- [Features](#-features)
- [Screenshots](#-screenshots)
- [Quick Start](#-quick-start)
- [Tech Stack](#️-tech-stack)
- [Development](#-development)
- [FAQ](#-faq)
- [Contributing](#-contributing)
- [License](#-license)
AI coding tools assume a single account with a single quota. When that quota runs out, you have to switch accounts by hand, and you usually have no idea how many tokens you burned.
---
Gemini Nexus is an Electron desktop application that runs a local gateway (an embedded NestJS server) in front of a pool of Google Gemini and Claude accounts. Your IDE points at the local gateway once; the gateway picks an account, translates the protocol, retries on quota or rate-limit errors, and records usage.
## ✨ Why Gemini Nexus?
What it does:
When using AI-powered IDEs and coding tools, have you ever run into these problems?
- Manages a pool of Google/Claude accounts with per-account status (active, rate limited, expired) and quota snapshots.
- Serves an OpenAI-compatible surface (`/v1/chat/completions`, `/v1/models`, `/v1/completions`, `/v1/responses`, images and audio routes), an Anthropic-compatible surface (`/v1/messages`) and the Gemini-native surface (`/v1beta/models`, `countTokens`).
- Streams responses over SSE and maps model names in both directions (for example `claude-sonnet-4-5` to the configured Gemini/Claude target).
- Stores tokens and keys encrypted at rest (AES-256-GCM) with an OS keychain wrapper, falling back to a local key file.
- Shows local analytics: token usage, cost estimates, model distribution, request traffic and request replay.
- 😫 Single account quota runs out quickly, requiring frequent manual switching
- 🔄 Managing multiple Google/Claude accounts is cumbersome
- 📊 Don't know how much quota or tokens you've consumed
- 🔌 Need a reliable local API proxy that speaks OpenAI/Anthropic protocols
- 🔍 Can't see what's actually being sent/received by the proxy
It is a personal desktop tool, not a hosted service: everything runs on your machine.
**Gemini Nexus** solves all of these. It's a professional Electron desktop application that acts as an intelligent gateway between your development tools and Google Gemini / Claude AI:
## How it works
- ✅ **Unlimited Account Pool** — Add any number of Google Gemini & Claude accounts
- ✅ **Smart Auto-Switching** — Automatically rotates to the next available account when quota is low or rate-limited
- ✅ **Real-time Usage Analytics** — SaaS-grade dashboard with area charts, trend indicators, and model distribution
- ✅ **Full Proxy Observability** — Live traffic monitor, request replay, and model capabilities inspector
- ✅ **OpenAI & Anthropic Compatible** — Drop-in replacement proxy for Cursor, Windsurf, OpenCode, and any OpenAI-compatible tool
- ✅ **Secure by Default** — AES-256-GCM encryption with OS-native credential management
```text
IDE / CLI (Cursor, Windsurf, OpenCode, Claude Code, custom scripts)
| OpenAI /v1/chat/completions Anthropic /v1/messages Gemini /v1beta
v
Gemini Nexus (Electron app)
+-- Renderer: React 19 UI (accounts, usage, proxy, settings)
+-- Main process: IPC, SQLite storage, encryption, tray
+-- Gateway: NestJS + Fastify, bound to port 8045 (8046 in development)
| 1. authenticate the request (Bearer / x-api-key / x-goog-api-key)
| 2. choose an account from the pool (quota, rate limit, circuit breaker)
| 3. translate the protocol and call the upstream API
| 4. stream the answer back and persist usage metrics
v
Google Gemini API / Claude API (per-account OAuth credentials)
```
---
- The gateway is started by the desktop app (Proxy page, or auto-start if enabled). It is a child process, so closing the app stops it.
- Account selection honours a scheduling mode (`balance`, `cache-first`, `performance-first`), a preferred account, per-account upstream proxy URLs, and a circuit breaker with backoff steps.
- Requests are recorded locally (SQLite) and can be replayed from the Proxy page for debugging.
## 🎯 Features
## Stack
<table>
<tr>
<td width="50%">
<h3>☁️ Cloud Account Pool</h3>
<ul>
<li>Add unlimited Google Gemini / Claude accounts via OAuth</li>
<li>Display avatar, email, status, and last used time</li>
<li>Real-time status monitoring (Active, Rate Limited, Expired)</li>
<li>Per-account proxy URL configuration</li>
<li>Device identity profile management with history</li>
</ul>
</td>
<td width="50%">
<h3>📊 Usage Analytics Dashboard</h3>
<ul>
<li>Real-time token consumption with 15s auto-refresh</li>
<li>Area charts for daily/hourly prompt & completion tokens</li>
<li>Trend indicators (% change vs previous period)</li>
<li>Inline sparklines on stat cards</li>
<li>Model distribution ranking with horizontal bar charts</li>
<li>Prompt/Completion ratio visualization</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔌 Local API Proxy (Gateway)</h3>
<ul>
<li>OpenAI <code>/v1/chat/completions</code> compatible endpoint</li>
<li>Anthropic <code>/v1/messages</code> compatible endpoint</li>
<li>Full SSE streaming support (tested with Cursor, Windsurf, OpenCode)</li>
<li>Model mapping (e.g. <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
<li>Configurable port, timeout, and API key</li>
<li>Model visibility control (show/hide specific models)</li>
</ul>
</td>
<td width="50%">
<h3>🔄 Intelligent Auto-Switching</h3>
<ul>
<li>Unlimited pool mode with smart backup selection</li>
<li>Auto-switch when quota < 5% or rate-limited</li>
<li>Background monitoring every 5 minutes</li>
<li>Graceful fallback with status reason tracking</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>🔍 Proxy Observability (Advanced)</h3>
<ul>
<li><strong>Traffic Monitor</strong> — Live request/response log with latency, status, and model info</li>
<li><strong>Request Replay</strong> — Replay any recorded request for debugging</li>
<li><strong>Model Capabilities</strong> — Inspect vision, thinking, streaming, JSON mode support per model</li>
<li><strong>Developer Tools</strong> — cURL & Python code generation, one-click copy</li>
</ul>
</td>
<td width="50%">
<h3>🔐 Security & Encryption</h3>
<ul>
<li>AES-256-GCM encryption for all sensitive data</li>
<li>OS native credential manager integration (Keytar + SafeStorage)</li>
<li>Auto migration of legacy plaintext data</li>
<li>Encrypted token & quota storage per account</li>
</ul>
</td>
</tr>
<tr>
<td width="50%">
<h3>⚙️ Settings & Customization</h3>
<ul>
<li>Dark / Light / System theme support</li>
<li>Multi-language: English & Português (Brasil)</li>
<li>Per-account proxy URL override</li>
<li>Model visibility toggles</li>
<li>Log directory access</li>
</ul>
</td>
<td width="50%">
<h3>🖥️ Desktop Experience</h3>
<ul>
<li>Native Electron app with system tray integration</li>
<li>Collapsible sidebar with persistent state</li>
<li>Responsive layouts across all views</li>
<li>Status bar with live proxy and connection indicators</li>
<li>Error boundaries with user-friendly fallbacks</li>
</ul>
</td>
</tr>
</table>
| Layer | Choice |
|---|---|
| Desktop shell | Electron 41, Electron Forge 7 (Vite plugin) |
| UI | React 19, TypeScript 5.9, TanStack Router + Query, Tailwind CSS v4, Radix UI, Nivo charts |
| Gateway | NestJS 11 on Fastify 5, RxJS, SSE streaming |
| Storage | better-sqlite3 + Drizzle ORM / raw SQL, JSON config in the app data dir |
| Secrets | Node crypto AES-256-GCM, `safeStorage` / `keytar` with file fallback |
| Validation | Zod schemas, class-validator |
| Internationalization | react-i18next (en, zh-CN, ru, vi) |
| Tests | Vitest (unit), Testing Library, Playwright (E2E, local only) |
| Package manager | pnpm 10.11.0 (`packageManager` field, Corepack) |
---
## Requirements
## 📸 Screenshots
- Node.js `^20.19.0 || >=22.12.0` (Vite 8 engine range). The repository pins 22.17.1 in `.nvmrc`.
- pnpm `10.11.0` (declared in `package.json`). With Corepack enabled, `pnpm` resolves to that version automatically.
- Native build toolchain for `better-sqlite3` and `keytar`: on Linux, `base-devel`/`build-essential`, `python3` and `libsecret`; on macOS, Xcode Command Line Tools; on Windows, Visual Studio Build Tools with the C++ workload. `pnpm install` runs `scripts/setup-dev.js --check-only`, which reports what is missing.
- A graphical session to run the desktop app itself (Electron).
- For installers: `dpkg`/`rpmbuild` on Linux, WiX Toolset on Windows.
<p align="center">
<img src="docs/assets/main.png" alt="Main" width="80%" />
</p>
<p align="center">
<img src="docs/assets/2usage.png" alt="Usage" width="80%" />
</p>
<p align="center">
<img src="docs/assets/3proxy.png" alt="Proxy" width="80%" />
</p>
<p align="center">
<img src="docs/assets/4routing.png" alt="Routing" width="80%" />
</p>
<p align="center">
<img src="docs/assets/5docs.png" alt="Docs" width="80%" />
</p>
<p align="center">
<img src="docs/assets/6connections.png" alt="Connections" width="80%" />
</p>
<p align="center">
<img src="docs/assets/7config.png" alt="Config" width="80%" />
</p>
---
## ⚡ Quick Start
### Prerequisites
- **Node.js** v20 or higher
- **npm** (this project uses `package-lock.json`)
### Build from Source
## Quick start
```bash
# Clone the repository
git clone https://github.com/evandrodevbr/GeminiNexus.git
cd GeminiNexus
# Install dependencies
npm install
# 1. install dependencies (lockfile is pnpm; do not use npm install)
corepack enable
pnpm install --frozen-lockfile
# Start development
npm start
# Build for production (Windows installer)
npm run make
# 2. run the desktop app (Electron + Vite HMR); the gateway listens on 8046 in dev
pnpm start
```
### Using with AI IDEs
Then, in the app:
Once the proxy is running, configure your IDE:
1. Add at least one Google or Claude account on the Accounts page (OAuth flow).
2. Open the Proxy page, enable the gateway and copy the generated API key.
3. Point your IDE or CLI at the local endpoint.
```plaintext
# In Cursor / Windsurf / OpenCode settings:
API Base URL: http://localhost:10100/v1
API Key: (copy from the Proxy page in the app)
Model: gemini-3-flash (or any mapped model)
```text
API base URL: http://localhost:8045/v1
API key: copied from the Proxy page
Model: gemini-3-flash (or any model id returned by GET /v1/models)
```
---
## Usage
## 🛠️ Tech Stack
| Category | Technologies |
|----------|-------------|
| **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/) |
| **Styling** | [Tailwind CSS v4](https://tailwindcss.com/), [Radix UI](https://www.radix-ui.com/), [Lucide Icons](https://lucide.dev/) |
| **State** | [TanStack Query](https://tanstack.com/query/latest), [TanStack Router](https://tanstack.com/router/latest) |
| **Backend** | [NestJS](https://nestjs.com/) (internal gateway), [ORPC](https://orpc.unnoq.com/) (type-safe RPC) |
| **Database** | [Better-SQLite3](https://github.com/WiseLibs/better-sqlite3), [Drizzle ORM](https://orm.drizzle.team/) |
| **Charts** | [Nivo](https://nivo.rocks/) (Line, Bar, Pie) |
| **i18n** | [react-i18next](https://react.i18next.com/) |
| **Testing** | [Vitest](https://vitest.dev/), [Playwright](https://playwright.dev/) |
---
## 💻 Development
Daily commands, all driven by `pnpm`:
```bash
# Start dev environment (Electron + Vite HMR)
npm start
# Run linting
npm run lint
# Type check
npm run type-check
# Format code
npm run format:write
# Run tests
npm test
pnpm start # dev app + gateway (port 8046)
pnpm run package # unpack the app for the current platform (out/)
pnpm run make # build installers (needs the extra tooling listed above)
pnpm test # Vitest unit/integration suite
pnpm run type-check # tsc --noEmit
pnpm run lint # ESLint
pnpm run format # Prettier check (pnpm run format:write to fix)
pnpm run test:e2e # Playwright E2E, needs a graphical session
```
### Project Structure
Settings live in the app data directory (`%APPDATA%\Gemini Nexus` on Windows, `~/Library/Application Support/Gemini Nexus` on macOS, `~/.config/Gemini Nexus` on Linux; `${app}-dev` names in development). Account metadata is stored in `~/.geminiNexus-agent/geminiNexus_accounts.json`.
```plaintext
## Screenshots
| | |
|---|---|
| ![Main dashboard](docs/assets/main.png) | ![Usage analytics](docs/assets/2usage.png) |
| ![Proxy monitor](docs/assets/3proxy.png) | ![Routing config](docs/assets/4routing.png) |
| ![Documentation](docs/assets/5docs.png) | ![Active connections](docs/assets/6connections.png) |
| ![Settings](docs/assets/7config.png) | |
## API
Routes actually mapped by the gateway (read from the running server):
| Route | Purpose |
|---|---|
| `GET /v1/models` | OpenAI-style model list built from the built-in mapping plus models collected from accounts. |
| `GET /v1/models/capabilities` | Per-model flags: vision, streaming, JSON mode, audio, image generation. |
| `GET /v1/status` | Gateway health, uptime, account counters and request metrics. |
| `POST /v1/chat/completions` | OpenAI Chat Completions, streaming and non-streaming. |
| `POST /v1/completions` | Legacy completions compatibility endpoint. |
| `POST /v1/responses` | OpenAI Responses-style endpoint. |
| `POST /v1/messages` | Anthropic Messages API. |
| `POST /v1/messages/chat/completions` | Anthropic-shaped requests over the chat completions path. |
| `POST /v1/batch/completions` | Batched chat completion requests. |
| `POST /v1/images/generations`, `POST /v1/images/edits` | Image endpoints. |
| `POST /v1/audio/transcriptions` | Audio transcription endpoint. |
| `GET /v1/events` | Server-sent events stream of gateway activity. |
| `GET /v1/replay/requests`, `POST /v1/replay/:requestId` | List recorded requests and replay one of them. |
| `GET /v1beta/models`, `GET /v1beta/models/:model` | Gemini-native model listing. |
| `POST /v1beta/models/:modelAction`, `POST /v1beta/models/:model/countTokens` | Gemini-native generate and token counting. |
Authentication: the gateway accepts the API key as `Authorization: Bearer <key>`, `x-api-key: <key>` or `x-goog-api-key: <key>`. If the configured key is empty, the gateway runs open. It listens on all network interfaces (`0.0.0.0`, see `src/server/main.ts`), so always set an API key on a machine that is reachable from a LAN.
## Build and release
```bash
pnpm run package # app bundle in out/<product>-<platform>-<arch>
pnpm run make # installers (deb, rpm, AppImage, zip on Linux; dmg/zip on macOS; WiX installer on Windows)
pnpm run make:win # Windows helper script
```
The gateway is statically bundled into the Electron app; there is no separate server deployment.
Release automation in `.github/workflows/`:
- `format.yaml` and `lint.yaml` run Prettier and ESLint on pull requests to `main`.
- `testing.yaml` runs the unit suite and `tsc --noEmit` on pushes and pull requests. E2E is intentionally excluded because it needs a graphical environment.
- `release.yml` runs semantic-release on `main`, which tags the version, updates `CHANGELOG.md` and creates the GitHub release.
- `publish.yaml` builds `win32`, `darwin` and `linux` for x64 and arm64 when the Release workflow succeeds, and uploads the installers to the release.
## Project structure
```text
src/
├── actions/ # App actions and flow orchestration
├── components/ # React UI components
│ ├── proxy/ # Proxy page components (advanced tabs)
│ ├── usage/ # Usage analytics components (StatCard, ChartCard)
│ └── ui/ # Base UI primitives (Radix-based)
├── hooks/ # Custom React hooks
├── ipc/ # Electron IPC + database handlers
├── layouts/ # Layout components (MainLayout + sidebar)
├── localization/ # i18n translation resources
├── routes/ # TanStack Router pages (index, usage, proxy, settings)
├── server/ # NestJS backend (proxy gateway service)
├── services/ # Service layer
├── types/ # TypeScript type definitions + Zod schemas
└── utils/ # Utility functions
├── main.ts, preload.ts, renderer.ts Electron entry points
├── routes/ TanStack Router pages: index (accounts), usage, proxy, settings
├── components/ React UI (accounts, proxy/advanced, usage, ui primitives)
├── server/ Embedded gateway
│ ├── main.ts NestJS bootstrap (port and process lifecycle)
│ ├── modules/proxy/ controllers, proxy service, token manager, SSE, replay, metrics
│ └── modules/usage/ token usage service
├── ipc/ Electron IPC handlers (accounts, config, database, proxy, tray, ...)
├── lib/geminiNexus/ protocol mappers, model mapping, retry and streaming helpers
├── localization/ i18n resources
├── utils/ paths, logging, encryption, traffic logger
└── tests/ unit, integration and e2e suites
docs/ OpenCode integration, cloud features, Proxyman guides, brand notes
scripts/ dev environment setup, Windows build helper, asset generation
```
---
## Verification
## ❓ FAQ
Checks used before merging, all run with `pnpm`:
<details>
<summary><b>Q: The app won't start?</b></summary>
| Command | Notes |
|---|---|
| `pnpm install --frozen-lockfile` | Run it with a native toolchain, or add `--ignore-scripts` to skip compiling `better-sqlite3`/`keytar` (the unit tests mock both). |
| `pnpm test` | Vitest suite under `src/tests/unit` and `src/tests/integration`. |
| `pnpm run type-check` | `tsc --noEmit`. |
| `pnpm run lint` | ESLint; reports warnings (mostly `no-explicit-any`) but no errors. |
| `pnpm run format` | Prettier check. |
| `pnpm run test:e2e` | Not part of CI: Playwright drives Electron and needs a graphical session. |
1. Make sure all dependencies are installed: `npm install`
2. Check if Node.js version is >= 20
3. Try deleting `node_modules` and reinstalling
4. On Windows, ensure the WiX Toolset is available for `npm run make`
Electron packaging, real account switching and the E2E suite need separate validation on each platform.
</details>
## Current state and limitations
<details>
<summary><b>Q: Account login failed?</b></summary>
- Upstream calls cannot be exercised without real Google/Claude accounts. With an empty pool the gateway starts, lists models and answers `429 No available accounts (all exhausted or rate limited)` on inference routes.
- The app is desktop-only (Windows, macOS, Linux). There is no headless or container mode, because the gateway is a child process of the Electron app.
- Accounts are added through the app UI and the OAuth flow; there is no CLI to import credentials.
- UI languages are en, zh-CN, ru and vi. The default language in `DEFAULT_APP_CONFIG` is `zh-CN`.
- Windows installers are unsigned (SmartScreen warning). macOS builds are unsigned and unnotarized.
- The E2E suite does not run in CI.
- `src/tests/GeminiNexusCoreFeatures.test.ts` sits outside the Vitest `include` globs, so it never runs, and several of its cases no longer match the current request mapper.
- `response_format: { type: 'json_schema' }` sets the JSON mime type but the schema itself is not forwarded upstream (`buildGenerationConfig` in `src/lib/geminiNexus/ClaudeRequestMapper.ts`).
- The project is mid-migration to a single license: the code inherited from AntigravityManager is CC BY-NC-SA 4.0 (non-commercial), while the new code is MIT. See the license section.
- `pnpm install` reports `Ignored build scripts: sharp`. `sharp` is only used by `scripts/generate-assets.ts` for icon generation, and its prebuilt binaries are installed through optional dependencies.
1. Ensure network connection is working
2. Try clearing app data and logging in again
3. Check if the account is restricted by Google/Claude
## Documentation
</details>
| Document | Content |
|---|---|
| [`docs/OpencodeAPI.md`](docs/OpencodeAPI.md) | Wiring OpenCode to the local custom endpoint |
| [`docs/cloud_features.md`](docs/cloud_features.md) | Cloud account management and switching behaviour |
| [`docs/proxyman-debugging.md`](docs/proxyman-debugging.md) | Debugging upstream traffic with Proxyman |
| [`docs/proxyman-install.md`](docs/proxyman-install.md) | Proxyman setup for contributors |
| [`docs/brand-briefing.md`](docs/brand-briefing.md) | Naming, positioning and visual notes |
| [`CONTRIBUTING.md`](CONTRIBUTING.md), [`AGENTS.md`](AGENTS.md) | Contribution rules and repository conventions |
| [`CHANGELOG.md`](CHANGELOG.md) | Release history |
<details>
<summary><b>Q: IDE can't connect to the proxy?</b></summary>
## License
1. Make sure the proxy is running (green indicator in the status bar)
2. Verify the port matches your IDE config (default: `10100`)
3. Copy the API key from the Proxy page and paste it in your IDE settings
4. Check that at least one account is active in the Accounts page
Dual license:
</details>
- Original code from [AntigravityManager](https://github.com/Draculabo/AntigravityManager) by [Draculabo](https://github.com/Draculabo): [CC BY-NC-SA 4.0](LICENSE).
- All new code, features and architecture by [evandrodevbr](https://github.com/evandrodevbr): [MIT](LICENSE-MIT).
<details>
<summary><b>Q: Token counts show zero?</b></summary>
The project is being migrated to MIT as the remaining original code is rewritten.
1. This was a known bug in earlier versions — update to the latest version
2. Token tracking now correctly handles streaming metadata and estimated counts
3. Usage data refreshes every 15 seconds on the Usage Analytics page
## Credits
</details>
---
## 🤝 Contributing
Contributions are welcome! Please read `CONTRIBUTING.md` for details.
---
## 📄 License
[CC BY-NC-SA 4.0](LICENSE)
---
## ⚠️ Disclaimer
> [!WARNING]
> **For Educational Purposes Only**
>
> This project is intended solely for educational and research purposes. It is provided "as-is" without any warranty. **Commercial use is strictly prohibited.**
>
> By using this software, you agree that you will not use it for any commercial purposes, and you are solely responsible for ensuring your use complies with all applicable laws and regulations. The authors and contributors are not responsible for any misuse or damages arising from the use of this software.
---
## 🙏 Credits
**Gemini Nexus** is a significantly enhanced and expanded version of the original project [AntigravityManager](https://github.com/Draculabo/AntigravityManager) created by [Draculabo](https://github.com/Draculabo). All fundamental architecture and design were made by the original author.
Originally forked from [AntigravityManager](https://github.com/Draculabo/AntigravityManager). Since the fork the codebase has been largely rewritten by [evandrodevbr](https://github.com/evandrodevbr): UI/UX, usage analytics, traffic monitor, environment isolation, CI/CD, test infrastructure and documentation.
+100 -82
View File
@@ -26,19 +26,22 @@
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
</p>
> [!IMPORTANT]
> **Usuários do Windows:** Se você vir um aviso do SmartScreen **"O Windows protegeu o seu computador"** durante a instalação, clique em **"Mais informações"** e depois em **"Executar mesmo assim"**. Isso é um aviso de segurança esperado para aplicativos novos e não assinados.
---
## 📖 Sumário
- [Por que o Gemini Nexus?](#-por-que-o-gemini-nexus)
- [Funcionalidades](#-funcionalidades)
- [Screenshots](#-screenshots)
- [Início Rápido](#-início-rápido)
- [Stack Tecnológica](#️-stack-tecnológica)
- [Desenvolvimento](#-desenvolvimento)
- [FAQ](#-faq)
- [Contribuindo](#-contribuindo)
- [Licença](#-licença)
- [✨ Por que o Gemini Nexus?](#-por-que-o-gemini-nexus)
- [🎯 Funcionalidades](#-funcionalidades)
- [📸 Screenshots](#-screenshots)
- [⚡ Instalação e Início Rápido](#-instalação-e-início-rápido)
- [🛠️ Stack Tecnológica](#️-stack-tecnológica)
- [💻 Desenvolvimento](#-desenvolvimento)
- [❓ FAQ](#-faq)
- [🤝 Contribuindo](#-contribuindo)
- [📄 Licença](#-licença)
---
@@ -46,20 +49,22 @@
Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes problemas?
- 😫 A cota da conta única acaba rápido, exigindo trocas manuais frequentes
- 🔄 Gerenciar múltiplas contas do Google/Claude é cansativo
- 📊 Não sabe quantos tokens já consumiu ou quanta cota resta
- 🔌 Precisa de um proxy local confiável que fale os protocolos OpenAI/Anthropic
- 🔍 Não consegue ver o que está sendo enviado/recebido pelo proxy
- 😫 **Limites de cota:** A cota da conta única acaba rápido, exigindo trocas manuais frequentes.
- 🔄 **Gestão de contas:** Gerenciar múltiplas contas do Google/Claude é cansativo.
- 📊 **Uso às cegas:** Você não sabe quantos tokens já consumiu ou quanta cota resta.
- 🔌 **Problemas de integração:** Precisa de um proxy local confiável que fale os protocolos OpenAI/Anthropic nativamente.
- 🔍 **Falta de transparência:** Não consegue ver o que está sendo enviado ou recebido pelo proxy nos bastidores.
**Gemini Nexus** resolve tudo isso. É um aplicativo desktop profissional em Electron que funciona como um gateway inteligente entre suas ferramentas de desenvolvimento e o Google Gemini / Claude AI:
O **Gemini Nexus** resolve tudo isso. É um aplicativo desktop profissional em Electron que funciona como um gateway inteligente entre suas ferramentas de desenvolvimento e o Google Gemini / Claude AI.
- ✅ **Pool de Contas Ilimitado** — Adicione quantas contas Google Gemini & Claude quiser
- ✅ **Troca Automática Inteligente** — Rotaciona automaticamente para a próxima conta disponível quando a cota está baixa ou com rate limit
- ✅ **Analytics de Uso em Tempo Real** — Dashboard nível SaaS com gráficos de área, indicadores de tendência e distribuição por modelo
- ✅ **Observabilidade Completa do Proxy** — Monitor de tráfego ao vivo, replay de requisições e inspetor de capacidades dos modelos
- ✅ **Compatível com OpenAI & Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode e qualquer ferramenta compatível com OpenAI
- ✅ **Seguro por Padrão** — Criptografia AES-256-GCM com gerenciamento nativo de credenciais do SO
### Principais Vantagens
- ✅ **Pool de Contas Ilimitado** — Adicione quantas contas Google Gemini & Claude quiser.
- ✅ **Troca Automática Inteligente** — Rotaciona automaticamente para a próxima conta disponível quando a cota está baixa ou com rate limit.
- ✅ **Analytics de Uso em Tempo Real** — Dashboard nível SaaS com gráficos de área, indicadores de tendência e distribuição por modelo.
- ✅ **Observabilidade Completa do Proxy** — Monitor de tráfego ao vivo, replay de requisições e inspetor de capacidades dos modelos.
- ✅ **Compatível com OpenAI & Anthropic** — Proxy drop-in para Cursor, Windsurf, OpenCode e qualquer ferramenta compatível com OpenAI.
- ✅ **Seguro por Padrão** — Criptografia AES-256-GCM com gerenciamento nativo de credenciais do sistema operacional.
---
@@ -98,7 +103,7 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
<li>Streaming SSE completo (testado com Cursor, Windsurf, OpenCode)</li>
<li>Mapeamento de modelos (ex: <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
<li>Porta, timeout e API key configuráveis</li>
<li>Controle de visibilidade de modelos (mostrar/ocultar modelos específicos)</li>
<li>Controle de visibilidade de modelos</li>
</ul>
</td>
<td width="50%">
@@ -115,9 +120,9 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
<td width="50%">
<h3>🔍 Observabilidade do Proxy (Avançado)</h3>
<ul>
<li><strong>Monitor de Tráfego</strong> — Log ao vivo de requisições/respostas com latência, status e informações do modelo</li>
<li><strong>Monitor de Tráfego</strong> — Log ao vivo de requisições/respostas com latência e informações do modelo</li>
<li><strong>Replay de Requisições</strong> — Reproduza qualquer requisição registrada para debugging</li>
<li><strong>Capacidades dos Modelos</strong> — Inspecione suporte a visão, thinking, streaming e modo JSON por modelo</li>
<li><strong>Capacidades dos Modelos</strong> — Inspecione suporte a visão, thinking, streaming e modo JSON</li>
<li><strong>Ferramentas para Devs</strong> — Geração de código cURL & Python, cópia com um clique</li>
</ul>
</td>
@@ -125,7 +130,7 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
<h3>🔐 Segurança e Criptografia</h3>
<ul>
<li>Criptografia AES-256-GCM para todos os dados sensíveis</li>
<li>Integração com gerenciador de credenciais nativo do SO (Keytar + SafeStorage)</li>
<li>Integração com gerenciador de credenciais nativo (Keytar + SafeStorage)</li>
<li>Migração automática de dados legados em texto plano</li>
<li>Armazenamento criptografado de tokens & cotas por conta</li>
</ul>
@@ -159,7 +164,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>
@@ -184,14 +188,32 @@ Ao usar IDEs e ferramentas de programação com IA, você já enfrentou estes pr
---
## ⚡ Início Rápido
## ⚡ Instalação e Início Rápido
### Pré-requisitos
### 📦 Download
Você pode baixar os binários pré-compilados mais recentes para Windows, macOS e Linux na nossa [página de Releases](https://github.com/evandrodevbr/GeminiNexus/releases).
_(Consulte o aviso no topo desta página se você estiver usando o Windows e encontrar o prompt do SmartScreen)._
### 🔌 Usando com IDEs de IA
Com o aplicativo rodando e pelo menos uma conta adicionada, configure sua IDE preferida (Cursor, Windsurf, OpenCode, etc.):
```plaintext
API Base URL: http://localhost:8045/v1
API Key: (copie da página Proxy no app)
Model: gemini-3-flash (ou qualquer modelo mapeado)
```
### 🛠️ Compilando pelo Código Fonte
#### Pré-requisitos
- **Node.js** v20 ou superior
- **npm** (este projeto usa `package-lock.json`)
### Compilando pelo Código Fonte
#### Passos
```bash
# Clone o repositório
@@ -204,36 +226,25 @@ npm install
# Inicie o modo de desenvolvimento
npm start
# Compile para produção (instalador Windows)
# Compile para produção (ex: instalador Windows)
npm run make
```
### Usando com IDEs de IA
Com o proxy em execução, configure sua IDE:
```plaintext
# Nas configurações do Cursor / Windsurf / OpenCode:
API Base URL: http://localhost:10100/v1
API Key: (copie da página Proxy no app)
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/) |
---
@@ -281,41 +292,44 @@ src/
## ❓ FAQ
<details>
<summary><b>P: O app não inicia?</b></summary>
<summary><b>P: Aviso do Windows SmartScreen ("O Windows protegeu o seu computador") durante a instalação?</b></summary>
1. Certifique-se de que todas as dependências estão instaladas: `npm install`
2. Verifique se a versão do Node.js é >= 20
3. Tente deletar `node_modules` e reinstalar
4. No Windows, certifique-se de que o WiX Toolset está disponível para `npm run make`
Sim, este é um aviso comum para novos aplicativos não assinados. Clique em **"Mais informações"** e depois em **"Executar mesmo assim"**. Veja o aviso no topo do README para mais detalhes.
</details>
<details>
<summary><b>P: O app não inicia ao compilar pelo código fonte?</b></summary>
1. Certifique-se de que todas as dependências estão instaladas: `npm install`.
2. Verifique se a versão do Node.js é >= 20.
3. Tente deletar `node_modules` e reinstalar.
4. No Windows, certifique-se de que o WiX Toolset está disponível para o comando `npm run make`.
</details>
<details>
<summary><b>P: O login da conta falhou?</b></summary>
1. Verifique se a conexão com a internet está funcionando
2. Tente limpar os dados do app e fazer login novamente
3. Verifique se a conta está restrita pelo Google/Claude
1. Verifique se a conexão com a internet está funcionando.
2. Tente limpar os dados do app e fazer login novamente.
3. Verifique se a conta está restrita pelo Google/Claude.
</details>
<details>
<summary><b>P: A IDE não conecta no proxy?</b></summary>
1. Certifique-se de que o proxy está rodando (indicador verde na barra de status)
2. Verifique se a porta corresponde à configuração da sua IDE (padrão: `10100`)
3. Copie a API key da página Proxy e cole nas configurações da sua IDE
4. Verifique se pelo menos uma conta está ativa na página de Contas
<summary><b>P: A IDE não consegue conectar ao proxy?</b></summary>
1. Certifique-se de que o proxy está rodando (indicador verde na barra de status).
2. Verifique se a porta corresponde à configuração da sua IDE (padrão: `8045`).
3. Copie a API key da página Proxy e cole nas configurações da sua IDE.
4. Verifique se pelo menos uma conta está ativa na página de Contas.
</details>
<details>
<summary><b>P: A contagem de tokens mostra zero?</b></summary>
1. Este era um bug conhecido em versões anteriores — atualize para a versão mais recente
2. O rastreamento de tokens agora lida corretamente com metadados de streaming e contagens estimadas
3. Os dados de uso são atualizados a cada 15 segundos na página de Analytics de Uso
1. Este era um bug conhecido em versões anteriores — por favor, atualize para a versão mais recente.
2. O rastreamento de tokens agora lida corretamente com metadados de streaming e contagens estimadas.
3. Os dados de uso são atualizados a cada 15 segundos na página de Analytics de Uso.
</details>
---
@@ -328,21 +342,25 @@ Contribuições são bem-vindas! Leia o `CONTRIBUTING.md` para mais detalhes.
## 📄 Licença
[CC BY-NC-SA 4.0](LICENSE)
Este projeto utiliza uma estrutura de licença dual:
---
- **Código original** do [AntigravityManager](https://github.com/Draculabo/AntigravityManager) por [Draculabo](https://github.com/Draculabo): Licenciado sob [CC BY-NC-SA 4.0](LICENSE).
- **Todo o código novo, funcionalidades e arquitetura** por [evandrodevbr](https://github.com/evandrodevbr): Licenciado sob a [Licença MIT](LICENSE-MIT).
## ⚠️ Aviso Legal
> [!WARNING]
> **Apenas para Fins Educacionais**
>
> Este projeto destina-se exclusivamente a fins de pesquisa e educação. Ele é fornecido "como está" sem qualquer garantia. **O uso comercial é estritamente proibido.**
>
> Ao usar este software, você concorda que não o utilizará para quaisquer fins comerciais, e que é o único responsável por garantir que seu uso esteja em conformidade com todas as leis e regulamentações aplicáveis. Os autores e contribuidores não se responsabilizam por qualquer uso indevido ou danos decorrentes do uso deste software.
> 🔄 **Aviso de Migração:** Este projeto está sendo ativamente migrado para uma licença totalmente open-source (MIT). À medida que o código original restante é progressivamente reescrito, todo o projeto será transicionado para MIT. Estamos comprometidos em tornar o Gemini Nexus 100% open-source.
---
## 🙏 Créditos
**Gemini Nexus** é uma versão significativamente aprimorada e expandida do projeto original [AntigravityManager](https://github.com/Draculabo/AntigravityManager) criado por [Draculabo](https://github.com/Draculabo). Toda a arquitetura fundamental e design foram criados pelo autor original.
Este projeto foi originalmente um fork de [AntigravityManager](https://github.com/Draculabo/AntigravityManager) criado por [Draculabo](https://github.com/Draculabo). O conceito inicial de proxy e a estrutura base do Electron foram baseados em seu trabalho.
Desde o fork, **a grande maioria do código base foi reescrita, redesenhada e expandida** por [evandrodevbr](https://github.com/evandrodevbr), incluindo mas não se limitando a:
- Redesign completo de UI/UX (navegação, contas, analytics de uso, gerenciamento de proxy)
- Dashboard de Analytics de Uso com rastreamento de custos, médias de tokens e integração com OpenRouter
- Monitor de tráfego com inspeção de tokens/custos por requisição
- Isolamento de ambiente (dev/prod), pipeline CI/CD e infraestrutura de testes
- Configuração Rápida para IDEs (OpenCode, Cursor, VS Code, Claude Code)
- Configuração personalizada de preços de modelos
- Toda a documentação e internacionalização (EN, PT-BR, ES, ZH-CN)
+159 -64
View File
@@ -23,25 +23,48 @@
<img src="https://img.shields.io/github/license/evandrodevbr/GeminiNexus?style=flat-square" alt="许可证" />
</a>
<img src="https://img.shields.io/badge/平台-Windows%20%7C%20macOS%20%7C%20Linux-informational?style=flat-square" alt="平台" />
<img src="https://img.shields.io/badge/electron-latest-47848F?style=flat-square&logo=electron&logoColor=white" alt="Electron" />
</p>
> [!IMPORTANT]
> **Windows 用户:** 如果在安装过程中看到 **“Windows 已保护你的电脑”** (SmartScreen) 警告,请点击 **“更多信息”** (More info),然后点击 **“仍要运行”** (Run anyway)。这是未签名的新应用预期的安全提示,属于正常现象。
---
## 📖 目录
- [✨ 为什么选择 Gemini Nexus?](#-为什么选择-gemini-nexus)
- [🎯 功能特性](#-功能特性)
- [📸 截图](#-截图)
- [⚡ 安装与快速开始](#-安装与快速开始)
- [🛠️ 技术栈](#️-技术栈)
- [💻 开发](#-开发)
- [❓ 常见问题](#-常见问题)
- [🤝 参与贡献](#-参与贡献)
- [📄 许可证](#-许可证)
---
## ✨ 为什么选择 Gemini Nexus?
- 😫 单个账户配额很快用完,需要频繁手动切换
- 🔄 管理多个 Google/Claude 账户非常繁琐
- 📊 不知道已经消耗了多少 Token 或还剩多少配额
- 🔌 需要一个可靠的本地 API 代理,支持 OpenAI/Anthropic 协议
在使用 AI 驱动的 IDE 和编程工具时,你是否遇到过这些问题?
**Gemini Nexus** 解决所有这些问题。它是一个专业的 Electron 桌面应用,作为你的开发工具和 Google Gemini / Claude AI 之间的智能网关:
- 😫 **配额限制:** 单个账户配额很快用完,需要频繁手动切换。
- 🔄 **账户管理:** 管理多个 Google/Claude 账户非常繁琐。
- 📊 **盲目使用:** 不知道已经消耗了多少 Token 或还剩多少配额。
- 🔌 **集成问题:** 需要一个可靠的本地 API 代理,并且必须原生支持 OpenAI/Anthropic 协议。
- 🔍 **缺乏透明度:** 无法查看代理在后台实际发送或接收的数据。
- ✅ **无限账户池** — 添加任意数量的 Google Gemini 和 Claude 账户
- ✅ **智能自动切换** — 当配额不足或被限速时自动轮换到下一个可用账户
- ✅ **实时使用分析** — SaaS 级仪表板,配备面积图、趋势指标和模型分布
- ✅ **完整的代理可观测性** — 实时流量监控、请求重放和模型能力检查器
- ✅ **兼容 OpenAI 和 Anthropic** — 即插即用的代理,支持 Cursor、Windsurf、OpenCode
- ✅ **默认安全** — AES-256-GCM 加密,配合操作系统原生凭证管理
**Gemini Nexus** 解决所有这些问题。它是一个专业的 Electron 桌面应用,作为你的开发工具和 Google Gemini / Claude AI 之间的智能网关。
### 核心价值主张
- ✅ **无限账户池** — 添加任意数量的 Google Gemini 和 Claude 账户。
- ✅ **智能自动切换** — 当配额不足或被限速时自动轮换到下一个可用账户。
- ✅ **实时使用分析** — SaaS 级仪表板,配备面积图、趋势指标和模型分布。
- ✅ **完整的代理可观测性** — 实时流量监控、请求重放和模型能力检查器。
- ✅ **兼容 OpenAI 和 Anthropic** — 即插即用的代理,支持 Cursor、Windsurf、OpenCode 以及任何兼容 OpenAI 的工具。
- ✅ **默认安全** — AES-256-GCM 加密,配合操作系统原生凭证管理。
---
@@ -77,10 +100,10 @@
<ul>
<li>兼容 OpenAI <code>/v1/chat/completions</code></li>
<li>兼容 Anthropic <code>/v1/messages</code></li>
<li>完整的 SSE 流式传输支持</li>
<li>完整的 SSE 流式传输支持(已在 Cursor, Windsurf, OpenCode 测试)</li>
<li>模型映射(如 <code>claude-sonnet-4-6</code> → <code>gemini-3-flash</code>)</li>
<li>可配置端口、超时时间和 API 密钥</li>
<li>模型可见性控制</li>
<li>模型可见性控制(隐藏/显示特定模型)</li>
</ul>
</td>
<td width="50%">
@@ -97,17 +120,17 @@
<td width="50%">
<h3>🔍 代理可观测性(高级)</h3>
<ul>
<li><strong>流量监控</strong> — 实时请求/响应日志</li>
<li><strong>流量监控</strong> — 实时请求/响应日志,含延迟和模型信息</li>
<li><strong>请求重放</strong> — 重放任意请求进行调试</li>
<li><strong>模型能力</strong> — 检查视觉、思考、流式、JSON 模式</li>
<li><strong>开发者工具</strong> — cURL 和 Python 代码生成</li>
<li><strong>模型能力</strong> — 检查每个模型对视觉、思考、流式、JSON 模式的支持</li>
<li><strong>开发者工具</strong> — cURL 和 Python 代码生成,一键复制</li>
</ul>
</td>
<td width="50%">
<h3>🔐 安全与加密</h3>
<ul>
<li>所有敏感数据采用 AES-256-GCM 加密</li>
<li>集成操作系统原生凭证管理器</li>
<li>集成操作系统原生凭证管理器 (Keytar + SafeStorage)</li>
<li>自动迁移旧版明文数据</li>
<li>每个账户的 Token 和配额均加密存储</li>
</ul>
@@ -119,6 +142,7 @@
<ul>
<li>深色 / 浅色 / 跟随系统主题</li>
<li>多语言:English & Português (Brasil)</li>
<li>每个账户可独立覆盖代理 URL</li>
<li>模型可见性开关</li>
<li>日志目录访问</li>
</ul>
@@ -128,8 +152,9 @@
<ul>
<li>原生 Electron 应用,系统托盘集成</li>
<li>可折叠侧边栏,状态持久化</li>
<li>响应式布局</li>
<li>响应式布局适配所有视图</li>
<li>状态栏实时显示代理和连接指标</li>
<li>错误边界处理以及友好的用户提示</li>
</ul>
</td>
</tr>
@@ -163,88 +188,158 @@
---
## ⚡ 快速开始
## ⚡ 安装与快速开始
### 前置要求
### 📦 下载
- **Node.js** v20 或更高版本
- **npm**
您可以从我们的 [Releases 页面](https://github.com/evandrodevbr/GeminiNexus/releases) 下载适用于 Windows、macOS 和 Linux 的最新预编译版本。
### 从源码构建
_(如果您使用的是 Windows 并在安装时遇到了 SmartScreen 提示,请参阅页面顶部的警告)。_
```bash
git clone https://github.com/evandrodevbr/GeminiNexus.git
cd GeminiNexus
npm install
npm start
### 🔌 配合 AI IDE 使用
# 构建生产版本
npm run make
```
### 配合 AI IDE 使用
在代理运行并至少添加一个账户后,配置您首选的 IDE(Cursor、Windsurf、OpenCode 等):
```plaintext
API Base URL: http://localhost:10100/v1
API Base URL: http://localhost:8045/v1
API Key: (从应用的 Proxy 页面复制)
Model: gemini-3-flash
Model: gemini-3-flash (或您选择的任何映射模型)
```
### 🛠️ 从源码构建
#### 前置要求
- **Node.js** v20 或更高版本
- **npm** (本项目使用 `package-lock.json`)
#### 步骤
```bash
# 克隆仓库
git clone https://github.com/evandrodevbr/GeminiNexus.git
cd GeminiNexus
# 安装依赖
npm install
# 启动开发环境
npm start
# 构建生产版本(例如 Windows 安装程序)
npm run make
```
---
## 🛠️ 技术栈
| 分类 | 技术 |
|------|------|
| **核心** | 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 |
---
## 💻 开发
```bash
# 启动开发环境 (Electron + Vite HMR)
npm start
# 运行代码规范检查
npm run lint
# 类型检查
npm run type-check
# 格式化代码
npm run format:write
# 运行测试
npm test
```
---
## ❓ 常见问题
<details>
<summary><b>问:应用无法启动?</b></summary>
<summary><b>问:安装时出现 Windows SmartScreen 警告("Windows 已保护你的电脑")?</b></summary>
1. 确保依赖已安装:`npm install`
2. Node.js 版本 >= 20
3. 尝试删除 `node_modules` 并重新安装
是的,对于未签名的新应用,这是一个常见的警告。请点击 **"更多信息"** (More info),然后点击 **"仍要运行"** (Run anyway)。详情请参阅 README 顶部的提示。
</details>
<details>
<summary><b>问:IDE 无法连接代理?</b></summary>
1. 确保代理正在运行(状态栏绿色指示器)
2. 确认端口一致(默认 `10100`)
3. 从 Proxy 页面复制 API 密钥
4. 确保至少有一个账户处于活跃状态
<summary><b>问:从源码构建时应用无法启动?</b></summary>
1. 确保所有依赖均已安装:`npm install`
2. 检查 Node.js 版本是否 >= 20。
3. 尝试删除 `node_modules` 并重新安装。
4. 在 Windows 上,请确保已安装 WiX Toolset 以便执行 `npm run make` 命令。
</details>
<details>
<summary><b>问:账户登录失败?</b></summary>
1. 确保您的网络连接正常。
2. 尝试清除应用数据并重新登录。
3. 检查该账户是否已被 Google/Claude 限制。
</details>
<details>
<summary><b>问:IDE 无法连接到代理?</b></summary>
1. 确保代理正在运行(查看状态栏的绿色指示器)。
2. 确认端口与您 IDE 中的配置一致(默认为 `8045`)。
3. 直接从 Proxy 页面复制 API 密钥并粘贴到 IDE 设置中。
4. 确保在 Accounts 页面中至少有一个活跃的账户。
</details>
<details>
<summary><b>问:Token 计数显示为零?</b></summary>
1. 这是早期版本中的一个已知漏洞 — 请更新至最新版本。
2. 目前 Token 追踪功能已能正确处理流式元数据及预估计数。
3. 使用数据会在 Usage Analytics 页面每 15 秒自动刷新。
</details>
---
## 🤝 参与贡献
欢迎贡献代码!详情请参阅 `CONTRIBUTING.md`。
---
## 📄 许可证
[CC BY-NC-SA 4.0](LICENSE)
本项目采用双重许可结构:
---
- **原始代码**来自 [Draculabo](https://github.com/Draculabo) 的 [AntigravityManager](https://github.com/Draculabo/AntigravityManager):采用 [CC BY-NC-SA 4.0](LICENSE) 许可。
- **所有新代码、功能和架构**由 [evandrodevbr](https://github.com/evandrodevbr) 开发:采用 [MIT 许可证](LICENSE-MIT)。
## ⚠️ 免责声明
> [!WARNING]
> **仅供教育目的**
>
> 本项目仅供教育和研究目的使用。按"原样"提供,不提供任何担保。**严禁商业用途。**
> 🔄 **迁移通知:** 本项目正在积极迁移到完全开源许可证(MIT)。随着剩余原始代码的逐步重写,整个项目将过渡到 MIT。我们致力于将 Gemini Nexus 打造为 100% 开源项目。
---
## 🙏 致谢
**Gemini Nexus** 基于 [Draculabo](https://github.com/Draculabo) 创建的原始项目 [AntigravityManager](https://github.com/Draculabo/AntigravityManager) 大幅增强和扩展。所有基础架构和设计均由原作者完成。
本项目最初是从 [Draculabo](https://github.com/Draculabo) 创建的 [AntigravityManager](https://github.com/Draculabo/AntigravityManager) 分支而来的。最初的代理概念和早期的 Electron 框架基于他的工作。
自从分支以来,[evandrodevbr](https://github.com/evandrodevbr) **重写、重新设计并扩展了绝大部分代码库**,包括但不限于:
- 完整的 UI/UX 重新设计(导航、账户、使用分析、代理管理)
- 使用分析仪表板,包含成本跟踪、Token 平均值以及 OpenRouter 集成
- 流量监视器,提供按请求查看的 Token/成本检查
- 环境隔离(dev/prod)、CI/CD 流水线和测试基础设施
- IDE 快速设置(OpenCode、Cursor、VS Code、Claude Code)
- 自定义模型定价配置
- 所有文档和国际化(EN, PT-BR, ES, ZH-CN)
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
Whitespace-only changes.
-3
View File
@@ -1,3 +0,0 @@
@echo off
set PYTHONPATH=%~dp0
python -m cli.main %*
+48 -36
View File
@@ -2,16 +2,16 @@
## 1. Nome e Descrição
| Campo | Valor |
|-------|-------|
| **Nome** | Gemini Nexus |
| **Product Name** | Gemini Nexus |
| **Package Name** | gemini-nexus |
| **Versão** | 0.11.1 |
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
| Campo | Valor |
| ------------------- | -------------------------------------------------------------------- |
| **Nome** | Gemini Nexus |
| **Product Name** | Gemini Nexus |
| **Package Name** | gemini-nexus |
| **Versão** | 0.11.1 |
| **Descrição EN** | Professional multi-account manager for Google Gemini & Claude AI |
| **Descrição PT-BR** | Gerenciador multi-contas profissional para Google Gemini & Claude AI |
| **Autor** | evandrodevbr / Draculabo |
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
| **Autor** | evandrodevbr / Draculabo |
| **Licença** | CC-BY-NC-SA-4.0 (apenas fins educacionais) |
---
@@ -20,6 +20,7 @@
Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona como um **hub central (nexus)** para desenvolvedores que usam IDEs com IA.
### Funcionalidades Principais
- **Pool ilimitado de contas**: Adicionar múltiplas contas Google Gemini / Claude via OAuth
- **Auto-switching inteligente**: Troca automática de conta quando a cota acaba ou há rate limit
- **Monitoramento em tempo real**: Barras de progresso coloridas para visualizar uso de quota
@@ -27,6 +28,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
- **Segurança**: Criptografia AES-256-GCM para dados sensíveis
### Público-Alvo
- Desenvolvedores que usam IDEs com integração AI (Cursor, Windsurf, VS Code, Claude Code, etc.)
- Power users que precisam alternar entre múltiplas contas de IA
- Usuários que precisam de um proxy API local confiável
@@ -46,28 +48,32 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
## 4. Estilo Visual Atual
### Tema
- Dual theme (claro/escuro) com detecção automática do sistema
- Extremamente minimalista e quase monocromático
### Paleta de Cores (Tailwind OKLCH)
| Modo | Background | Foreground | Primary | Destructive | Destaque |
|------|-----------|------------|---------|-------------|----------|
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
| Modo | Background | Foreground | Primary | Destructive | Destaque |
| ---------- | ------------------------------ | --------------------- | ------- | --------------- | -------- |
| **Claro** | oklch(0.99 0 0) — quase branco | oklch(0 0 0) — preto | Preto | Vermelho | Azul |
| **Escuro** | oklch(0 0 0) — preto puro | oklch(1 0 0) — branco | Branco | Vermelho escuro | — |
### Cores Funcionais
- **Emerald/Green (`#10b981`)** — status ativo, quota alta, running
- **Amber/Yellow** — quota média, alertas
- **Rose/Red** — quota baixa, erro, stopped
- **Blue** — AI credits, informações premium
### Tipografia
- **Geist** (sans-serif) — fonte principal moderna e geométrica (da Vercel)
- **Geist Mono** (monospace) — dados técnicos, números de quota
- **Tomorrow** (display/futurista) — possíveis títulos estilizados
### Características de UI
- Bordas arredondadas (`0.5rem` / 8px)
- Sombras sutis
- Cards com hover states suaves
@@ -77,45 +83,50 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
## 5. Assets Visuais Existentes
| Arquivo | Descrição |
|---------|-----------|
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
| `src/assets/tray.png` | Ícone da system tray |
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
| Arquivo | Descrição |
| ---------------------------------------------------------------- | ---------------------------------------------------- |
| `docs/assets/logo.png` | Logo antiga (Antigravity) — 128x128, usada no README |
| `docs/assets/screenshot-main.png` | Screenshot da interface principal |
| `docs/assets/screenshot-proxy.png` | Screenshot da aba proxy |
| `docs/assets/screenshot-setting.png` | Screenshot de settings |
| `src/assets/icon.png` | Ícone do app (também é o logo antigo) |
| `src/assets/tray.png` | Ícone da system tray |
| `images/icon.ico` / `images/icon.icns` | Ícones de build para Windows/Mac |
| `images/favicon-*.png` | Favicons em múltiplos tamanhos |
| `images/32x32.png`, `64x64.png`, `128x128.png`, `[email protected]` | Ícones para AppImage Linux |
| `src/assets/fonts/geist/` | Fonte Geist (principal) |
| `src/assets/fonts/geist-mono/` | Fonte Geist Mono (código/dados) |
| `src/assets/fonts/tomorrow/` | Fonte Tomorrow (display, 4 pesos) |
---
## 6. Conceitos Visuais Sugeridos para a Logo
### A. Nodo / Conexão (Nexus)
- Ponto central conectado a múltiplos pontos (contas gerenciadas)
- Círculo central com 2-3 linhas finas irradiando
- Dois círculos interligados (Gemini + Claude)
### B. Infinito / Loop (Gemini + Proxy)
- Símbolo de infinito (∞) modificado como circuito/loop de dados
- Referência à dualidade "Gemini" (gêmeos)
- Curva contínua elegante com seta indicando fluxo
### C. Cubo / Hexágono (Nexus / Hub)
- Hexágono ou cubo isométrico minimalista
- Linhas finas, perspectiva suave
- Face "aberta" ou conectada
### D. Monograma "GN"
- Letras "G" e "N" abstraídas como nodo de rede
- Estilo geométrico, similar a Tomorrow ou Geist
### E. Proxy / Pipe (Fluxo de Dados)
- Duas barras paralelas com seta passando entre elas
- "Pipe" minimalista — conduz tráfego entre APIs
@@ -123,15 +134,15 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
## 7. Diretrizes para a Nova Logo
| Elemento | Especificação |
|----------|---------------|
| **Estilo** | Minimalista, geométrica, precisa, técnica |
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
| **Versões necessárias** | Dark mode e light mode |
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
| **Monocromática** | Deve funcionar em preto e branco puro |
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
| Elemento | Especificação |
| ---------------------------- | ------------------------------------------------------------------------- |
| **Estilo** | Minimalista, geométrica, precisa, técnica |
| **Cores principais** | Preto (`#000000`) e branco (`#ffffff`) |
| **Cor de acento (opcional)** | Emerald (`#10b981`) para "ativo/online" |
| **Versões necessárias** | Dark mode e light mode |
| **Tamanhos** | De 16x16 (tray) até 512x512 (macOS) |
| **Monocromática** | Deve funcionar em preto e branco puro |
| **Tipografia do wordmark** | Geométrica sans-serif, peso medium/semibold, tracking levemente expandido |
---
@@ -140,6 +151,7 @@ Gemini Nexus é um **aplicativo desktop profissional** (Electron) que funciona c
O app pede uma logo que evoque **conexão/nexo** (Nexus) com **múltiplos pontos** (contas) de forma geométrica e limpa, usando primariamente preto/branco com um toque sutil de verde (emerald) para representar o estado "ativo/online" central ao propósito do app.
A logo deve transmitir:
- **Tecnologia** e **precisão**
- **Conectividade** e **hub central**
- **Profissionalismo** e **confiança**
+7
View File
@@ -50,4 +50,11 @@ export default defineConfig([
'prefer-const': 'off',
},
},
{
// Node maintenance scripts are CommonJS; require() is the native API there.
files: ['scripts/**/*.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
},
]);
-44
View File
@@ -1,44 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs');
const path = require('path');
const projectRoot = process.cwd();
// Extensões permitidas
const allowedExtensions = /\.(ts|tsx)$/;
function walkAndFix(dir) {
const files = fs.readdirSync(dir);
for (const file of files) {
if (['node_modules', '.git', 'dist', 'out'].includes(file)) continue;
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walkAndFix(fullPath);
} else if (allowedExtensions.test(file)) {
let content = fs.readFileSync(fullPath, 'utf-8');
const original = content;
// Fix specific known broken identifiers
content = content.replace(/closeGemini Nexus/g, 'closeGeminiNexus');
content = content.replace(/startGemini Nexus/g, 'startGeminiNexus');
content = content.replace(/Gemini NexusVersion/g, 'GeminiNexusVersion');
content = content.replace(/Gemini NexusDb/g, 'GeminiNexusDb');
content = content.replace(/Gemini NexusStorage/g, 'GeminiNexusStorage');
content = content.replace(/Gemini NexusExecutable/g, 'GeminiNexusExecutable');
content = content.replace(/Gemini NexusCoreFeatures/g, 'GeminiNexusCoreFeatures');
content = content.replace(/getGemini Nexus/g, 'getGeminiNexus');
if (content !== original) {
fs.writeFileSync(fullPath, content, 'utf-8');
console.log(`Fixed TS errors in: ${fullPath}`);
}
}
}
}
walkAndFix(projectRoot);
console.log('Fix complete.');
Generated
-27
View File
@@ -1,27 +0,0 @@
{
"nodes": {
"nixpkgs": {
"locked": {
"lastModified": 1774106199,
"narHash": "sha256-US5Tda2sKmjrg2lNHQL3jRQ6p96cgfWh3J1QBliQ8Ws=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "6c9a78c09ff4d6c21d0319114873508a6ec01655",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"nixpkgs": "nixpkgs"
}
}
},
"root": "root",
"version": 7
}
-121
View File
@@ -1,121 +0,0 @@
{
description = "Antigravity Manager";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
};
outputs = {
self,
nixpkgs,
}: let
supportedSystems = ["x86_64-linux" "aarch64-linux"];
forAllSystems = nixpkgs.lib.genAttrs supportedSystems;
version = "0.10.0";
sources = {
"x86_64-linux" = {
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_amd64.deb";
sha256 = "5fc08b4a9308c71fc00e44b95d4fb3178182aea8821708fa51fdf2bf5dbbb856";
};
"aarch64-linux" = {
url = "https://github.com/Draculabo/AntigravityManager/releases/download/v${version}/Antigravity.Manager_${version}_arm64.deb";
sha256 = "666c9659efa66bb6be6a8e7fb0c384ceb799a17245eab374bf9b3d7901c5c4cb";
};
};
mkPackage = pkgs: let
system = pkgs.stdenv.hostPlatform.system;
source = sources.${system} or (throw "Unsupported system: ${system}");
in
pkgs.stdenv.mkDerivation {
pname = "antigravity-manager";
inherit version;
src = pkgs.fetchurl {
inherit (source) url sha256;
};
nativeBuildInputs = with pkgs; [
autoPatchelfHook
makeWrapper
zstd
];
buildInputs = with pkgs; [
alsa-lib
at-spi2-atk
atk
cups
dbus
expat
glib
gtk3
libsecret
libdrm
libxkbcommon
mesa
nspr
nss
pango
systemd
libx11
libxcomposite
libxdamage
libxext
libxfixes
libxrandr
libxcb
];
unpackPhase = ''
ar x $src
tar xf data.tar.* --no-same-permissions
'';
installPhase = ''
mkdir -p $out/bin $out/lib $out/share
cp -r usr/lib/antigravity-manager $out/lib/
cp -r usr/share/* $out/share/
ln -s $out/lib/antigravity-manager/antigravity-manager $out/bin/antigravity-manager
'';
postFixup = ''
# Wrap the executable so it finds Wayland/X11 and libsecret
wrapProgram $out/bin/antigravity-manager \
--add-flags "--enable-features=WaylandWindowDecorations" \
--add-flags "--ozone-platform-hint=auto" \
--prefix LD_LIBRARY_PATH : ${pkgs.lib.makeLibraryPath [pkgs.libsecret]}
if [ -e $out/share/applications/antigravity-manager.desktop ]; then
substituteInPlace $out/share/applications/antigravity-manager.desktop \
--replace "/usr/lib/antigravity-manager/antigravity-manager" "antigravity-manager"
fi
'';
meta = with pkgs.lib; {
description = "Professional multi-account manager for Google Gemini & Claude AI";
homepage = "https://github.com/Draculabo/AntigravityManager";
license = licenses.cc-by-nc-sa-40;
maintainers = [];
platforms = ["x86_64-linux" "aarch64-linux"];
mainProgram = "antigravity-manager";
};
};
in {
overlays.default = final: prev: {
antigravity-manager = mkPackage final;
};
packages = forAllSystems (system: let
pkgs = import nixpkgs {
inherit system;
config.allowUnfree = true;
};
in {
default = mkPackage pkgs;
});
};
}
+1 -6
View File
@@ -10,7 +10,6 @@ import type {
import { MakerDeb } from '@electron-forge/maker-deb';
import { MakerDMG } from '@electron-forge/maker-dmg';
import { MakerRpm } from '@electron-forge/maker-rpm';
import { MakerSquirrel } from '@electron-forge/maker-squirrel';
import { MakerWix } from '@electron-forge/maker-wix';
import { MakerZIP } from '@electron-forge/maker-zip';
import { AutoUnpackNativesPlugin } from '@electron-forge/plugin-auto-unpack-natives';
@@ -441,10 +440,6 @@ const config: ForgeConfig = {
},
},
makers: [
new MakerSquirrel({
setupIcon: 'images/icon.ico',
iconUrl: 'https://raw.githubusercontent.com/evandrodevbr/GeminiNexus/main/images/icon.ico',
}),
...(process.platform === 'win32' && process.arch === 'x64' && isWixAvailable()
? [
new MakerWix({
@@ -463,7 +458,7 @@ const config: ForgeConfig = {
},
['darwin'],
),
new MakerZIP({}, ['darwin']),
new MakerZIP({}, ['darwin', 'win32']),
appImageMaker,
new MakerRpm({}),
new MakerDeb({}),
+26 -9
View File
@@ -1,10 +1,11 @@
{
"name": "gemini-nexus",
"productName": "Gemini Nexus",
"version": "0.13.0",
"version": "0.15.5",
"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",
@@ -99,7 +99,6 @@
"vitest": "^4.0.8"
},
"dependencies": {
"@icons-pack/react-simple-icons": "^13.8.0",
"@nestjs/common": "^11.1.18",
"@nestjs/core": "^11.1.18",
"@nestjs/microservices": "^11.1.18",
@@ -116,19 +115,14 @@
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toast": "^1.2.15",
"@radix-ui/react-toggle": "^1.1.9",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@sentry/electron": "^7.7.0",
"@sentry/integrations": "^7.114.0",
"@tanstack/react-query": "^5.90.7",
"@tanstack/react-router": "^1.139.3",
"@tanstack/react-router-devtools": "^1.135.0",
@@ -170,9 +164,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",
-8
View File
@@ -1,8 +0,0 @@
const fs = require('fs');
let file = fs.readFileSync('src/routes/proxy.tsx', 'utf8');
// The Proxy layout also uses heavy cards
file = file.replace(/<Card>/g, '<Card className="bg-background border border-white/5 shadow-none">');
fs.writeFileSync('src/routes/proxy.tsx', file);
-11
View File
@@ -1,11 +0,0 @@
const fs = require('fs');
let file = fs.readFileSync('src/routes/settings.tsx', 'utf8');
// The settings page uses standard cards that look bulky in minimal dark mode
file = file.replace(/<div className="flex flex-col gap-6">/g, '<div className="flex flex-col gap-2">');
file = file.replace(/<Card>/g, '<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">');
file = file.replace(/<CardHeader>/g, '<CardHeader className="px-0 pt-4">');
file = file.replace(/<CardContent>/g, '<CardContent className="px-0">');
fs.writeFileSync('src/routes/settings.tsx', file);
+10 -296
View File
@@ -19,9 +19,6 @@ importers:
.:
dependencies:
'@icons-pack/react-simple-icons':
specifier: ^13.8.0
version: 13.13.0([email protected])
'@nestjs/common':
specifier: ^11.1.18
version: 11.1.19([email protected])([email protected])([email protected])([email protected])
@@ -70,15 +67,9 @@ importers:
'@radix-ui/react-dropdown-menu':
specifier: ^2.1.16
version: 2.1.16(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-icons':
specifier: ^1.3.2
version: 1.3.2([email protected])
'@radix-ui/react-label':
specifier: ^2.1.8
version: 2.1.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-navigation-menu':
specifier: ^1.2.14
version: 1.2.14(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-select':
specifier: ^2.2.6
version: 2.2.6(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
@@ -94,21 +85,12 @@ importers:
'@radix-ui/react-toast':
specifier: ^1.2.15
version: 1.2.15(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-toggle':
specifier: ^1.1.9
version: 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-toggle-group':
specifier: ^1.1.11
version: 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-tooltip':
specifier: ^1.2.8
version: 1.2.8(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@sentry/electron':
specifier: ^7.7.0
version: 7.12.0
'@sentry/integrations':
specifier: ^7.114.0
version: 7.114.0
'@tanstack/react-query':
specifier: ^5.90.7
version: 5.100.5([email protected])
@@ -227,9 +209,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 +376,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 +642,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'}
@@ -1102,12 +1077,6 @@ packages:
resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==}
engines: {node: '>=18.18'}
'@icons-pack/[email protected]':
resolution: {integrity: sha512-B5HhQMIpcSH4z8IZ8HFhD59CboHceKYMpPC9kAwGyKntvPdyJJv26DLu4Z1wAjcCLyrJhf11tMhiQGom9Rxb9g==}
engines: {node: '>=24', pnpm: '>=10'}
peerDependencies:
react: ^16.13 || ^17 || ^18 || ^19
'@img/[email protected]':
resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==}
engines: {node: '>=18'}
@@ -1160,183 +1129,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==}
@@ -2311,11 +2252,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-fyQIhGDhzfc9pK2kH6Pl9c4BDJGfMkPqkyIgYDthyNYoNg3wVhoJMMh19WS4Up/1KMPFVpNsT2q3WmXn2N1m6g==}
peerDependencies:
react: ^16.x || ^17.x || ^18.x || ^19.0.0 || ^19.0.0-rc
'@radix-ui/[email protected]':
resolution: {integrity: sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==}
peerDependencies:
@@ -2351,19 +2287,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-YB9mTFQvCOAQMHU+C/jVl96WmuWeltyUEpRJJky51huhds5W2FQr1J8D/16sQlf0ozxkPK8uF3niQMdUwZPv5w==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==}
peerDependencies:
@@ -2512,32 +2435,6 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-5umnS0T8JQzQT6HbPyO7Hh9dgd82NmS36DQr+X/YJ9ctFNCiiQd6IJAYYZ33LUwm8M+taCz5t2ui29fHZc4Y6Q==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@radix-ui/[email protected]':
resolution: {integrity: sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg==}
peerDependencies:
@@ -2710,42 +2607,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 +2727,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==}
@@ -3074,10 +2952,6 @@ packages:
resolution: {integrity: sha512-UaFeum3LUM1mB0d67jvKnqId1yWQjyqmaDV6kWngG03x+jqXb08tJdGpSoxjXZe13jFBbiBL/wKDDYIK7rCK4g==}
engines: {node: '>=18'}
'@sentry/[email protected]':
resolution: {integrity: sha512-YnanVlmulkjgZiVZ9BfY9k6I082n+C+LbZo52MTvx3FY6RE5iyiPMpaOh67oXEZRWcYQEGm+bKruRxLVP6RlbA==}
engines: {node: '>=8'}
'@sentry/[email protected]':
resolution: {integrity: sha512-i8WkwxvtioaOKbE957GhoWaAuyiaUdjBNtJeus2VIg5o3RZaM871Q57WcQ3t2TF+U0z+IIlWgwPgGsLcsrPrDw==}
peerDependencies:
@@ -3086,10 +2960,6 @@ packages:
'@sentry/node-native':
optional: true
'@sentry/[email protected]':
resolution: {integrity: sha512-BJIBWXGKeIH0ifd7goxOS29fBA8BkEgVVCahs6xIOXBjX1IRS6PmX0zYx/GP23nQTfhJiubv2XPzoYOlZZmDxg==}
engines: {node: '>=8'}
'@sentry/[email protected]':
resolution: {integrity: sha512-7WO0KuCDPSq3G54TVUSI1CKFJwB67LasG+n/gDMBqbrarzs/Yh/s34OOMU5gfVQpncxQAmQsy4nEboQms8iNqA==}
engines: {node: '>=18'}
@@ -3127,14 +2997,6 @@ packages:
'@opentelemetry/sdk-trace-base': ^1.30.1 || ^2.1.0
'@opentelemetry/semantic-conventions': ^1.39.0
'@sentry/[email protected]':
resolution: {integrity: sha512-tsqkkyL3eJtptmPtT0m9W/bPLkU7ILY7nvwpi1hahA5jrM7ppoU0IMaQWAgTD+U3rzFH40IdXNBFb8Gnqcva4w==}
engines: {node: '>=8'}
'@sentry/[email protected]':
resolution: {integrity: sha512-319N90McVpupQ6vws4+tfCy/03AdtsU0MurIE4+W5cubHME08HtiEWlfacvAxX+yuKFhvdsO4K4BB/dj54ideg==}
engines: {node: '>=8'}
'@sentry/[email protected]':
resolution: {integrity: sha512-Tlyg2cyFYp/icX58GWvfpvZr9NLdLs2/xyFVyS8pQ0faZWmoXic3FMzoXYHV1gsdMbL1Yy5WQvGJy8j1rS8LGA==}
engines: {node: '>= 14'}
@@ -3180,42 +3042,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 +3148,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==}
@@ -3815,6 +3667,7 @@ packages:
'@xmldom/[email protected]':
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
engines: {node: '>=10.0.0'}
deprecated: this version has critical issues, please update to the latest version
'@xtuc/[email protected]':
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
@@ -4961,10 +4814,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'}
@@ -5158,6 +5007,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
hasBin: true
peerDependencies:
jiti: '*'
@@ -5433,10 +5283,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'}
@@ -6252,9 +6098,6 @@ packages:
[email protected]:
resolution: {integrity: sha512-lsmMmGXBxXIK/VMLEj0kL6MtUs1kBGj1nTCzi6zgQoG1DEwqwt2DQyHxcLykceIxAnfE3hya7NuIh6PpC6S3fA==}
[email protected]:
resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==}
[email protected]:
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
@@ -6296,28 +6139,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==}
@@ -6362,9 +6201,6 @@ packages:
resolution: {integrity: sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==}
engines: {node: '>=6.11.5'}
[email protected]:
resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==}
[email protected]:
resolution: {integrity: sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA==}
engines: {node: '>=4'}
@@ -8107,10 +7943,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'}
@@ -8261,6 +8093,7 @@ packages:
[email protected]:
resolution: {integrity: sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==}
engines: {node: ^18 || >=20}
deprecated: unmaintained
hasBin: true
peerDependencies:
typescript: ^5.0.0
@@ -8874,10 +8707,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 +9102,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
@@ -9862,10 +9684,6 @@ snapshots:
'@humanwhocodes/[email protected]': {}
'@icons-pack/[email protected]([email protected])':
dependencies:
react: 19.2.5
'@img/[email protected]': {}
'@img/[email protected]':
@@ -10637,9 +10455,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
@@ -11263,10 +11081,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected]([email protected])':
dependencies:
react: 19.2.5
'@radix-ui/[email protected](@types/[email protected])([email protected])':
dependencies:
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
@@ -11309,28 +11123,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-collection': 1.1.7(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-compose-refs': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-dismissable-layer': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-id': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-presence': 1.1.5(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-callback-ref': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
'@radix-ui/react-use-layout-effect': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-use-previous': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-visually-hidden': 1.2.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
react: 19.2.5
react-dom: 19.2.5([email protected])
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@floating-ui/react-dom': 2.1.8([email protected]([email protected]))([email protected])
@@ -11498,32 +11290,6 @@ snapshots:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-context': 1.1.2(@types/[email protected])([email protected])
'@radix-ui/react-direction': 1.1.1(@types/[email protected])([email protected])
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-roving-focus': 1.1.11(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-toggle': 1.1.10(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
react: 19.2.5
react-dom: 19.2.5([email protected])
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
'@radix-ui/react-primitive': 2.1.3(@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])
'@radix-ui/react-use-controllable-state': 1.2.2(@types/[email protected])([email protected])
react: 19.2.5
react-dom: 19.2.5([email protected])
optionalDependencies:
'@types/react': 19.2.14
'@types/react-dom': 19.2.3(@types/[email protected])
'@radix-ui/[email protected](@types/[email protected](@types/[email protected]))(@types/[email protected])([email protected]([email protected]))([email protected])':
dependencies:
'@radix-ui/primitive': 1.1.3
@@ -11983,11 +11749,6 @@ snapshots:
'@sentry/[email protected]': {}
'@sentry/[email protected]':
dependencies:
'@sentry/types': 7.114.0
'@sentry/utils': 7.114.0
'@sentry/[email protected]':
dependencies:
'@sentry/browser': 10.49.0
@@ -11997,13 +11758,6 @@ snapshots:
- '@opentelemetry/exporter-trace-otlp-http'
- supports-color
'@sentry/[email protected]':
dependencies:
'@sentry/core': 7.114.0
'@sentry/types': 7.114.0
'@sentry/utils': 7.114.0
localforage: 1.10.0
'@sentry/[email protected](@opentelemetry/[email protected])(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected](@opentelemetry/[email protected]))(@opentelemetry/[email protected])':
dependencies:
'@sentry/core': 10.49.0
@@ -12062,12 +11816,6 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.40.0
'@sentry/core': 10.49.0
'@sentry/[email protected]': {}
'@sentry/[email protected]':
dependencies:
'@sentry/types': 7.114.0
'@sentry/[email protected]([email protected])':
dependencies:
'@sentry/bundler-plugin-core': 4.9.1([email protected])
@@ -14036,19 +13784,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 +14465,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
@@ -15593,10 +15321,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
immediate: 3.0.6
[email protected]:
dependencies:
immediate: 3.0.6
@@ -15687,10 +15411,6 @@ snapshots:
[email protected]: {}
[email protected]:
dependencies:
lie: 3.1.1
[email protected]:
dependencies:
p-locate: 2.0.0
@@ -16944,12 +16664,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 +17192,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
+3
View File
@@ -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
View File
@@ -6,7 +6,7 @@
* instead of Gitmoji (since the project history doesn't use emojis).
*/
const REPO_URL = 'https://github.com/Draculabo/AntigravityManager';
const REPO_URL = 'https://github.com/evandrodevbr/GeminiNexus';
const GITHUB_BASE_URL = 'https://github.com';
const FULL_CHANGELOG_URL = `${REPO_URL}/blob/main/CHANGELOG.md`;
@@ -244,7 +244,7 @@ const releaseConfig = {
[
'@semantic-release/git',
{
assets: ['CHANGELOG.md', 'package.json', 'package-lock.json', 'npm-shrinkwrap.json'],
assets: ['CHANGELOG.md', 'package.json', 'pnpm-lock.yaml'],
message: 'chore(release): ${nextRelease.version}\n\n${nextRelease.notes}',
},
],
-118
View File
@@ -1,118 +0,0 @@
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs');
const path = require('path');
const projectRoot = process.cwd();
// Extensões permitidas
const allowedExtensions = /\.(ts|tsx|js|jsx|json|md|html|jsonc|yml|yaml|css)$/;
// Arquivos/Pastas a ignorar (já curados ou irrelevantes)
const ignoreDirs = [
'node_modules',
'.git',
'.gemini',
'dist',
'out',
'build',
'images',
'docs',
'.planning',
];
const ignoreFiles = [
'README.md',
'README.pt-BR.md',
'CHANGELOG.md',
'forge.config.ts',
'package.json',
'rename.js',
'rename-deep.js',
'task.md',
'walkthrough.md',
];
function walkAndReplace(dir) {
const files = fs.readdirSync(dir);
for (const file of files) {
if (ignoreDirs.includes(file)) continue;
const fullPath = path.join(dir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
walkAndReplace(fullPath);
// Rename directory if it contains 'antigravity'
if (file.toLowerCase().includes('antigravity')) {
const newName = file.replace(/antigravity/gi, (match) => {
if (match === 'antigravity') return 'geminiNexus';
if (match === 'Antigravity') return 'GeminiNexus';
return 'geminiNexus';
});
const newPath = path.join(dir, newName);
fs.renameSync(fullPath, newPath);
console.log(`Renamed directory: ${fullPath} -> ${newPath}`);
}
} else {
if (ignoreFiles.includes(file)) continue;
if (allowedExtensions.test(file)) {
let content = fs.readFileSync(fullPath, 'utf-8');
const originalContent = content;
// Save original GitHub links
const repoUrlRegex = /github\.com\/Draculabo\/AntigravityManager/gi;
const tempUrlMarker = '___TEMP_GITHUB_URL___';
content = content.replace(repoUrlRegex, tempUrlMarker);
// 1. Strings exact match "Antigravity" -> "Gemini Nexus"
content = content.replace(/['"]Antigravity['"]/g, (match) => {
return match[0] + 'Gemini Nexus' + match[0];
});
// 2. String "Antigravity " -> "Gemini Nexus "
content = content.replace(/Antigravity /g, 'Gemini Nexus ');
// 3. String " Antigravity" -> " Gemini Nexus"
content = content.replace(/ Antigravity/g, ' Gemini Nexus');
// 4. Identifiers Antigravity -> GeminiNexus
content = content.replace(/Antigravity/g, 'GeminiNexus');
// 5. Identifiers antigravity -> geminiNexus
content = content.replace(/antigravity/g, 'geminiNexus');
// Restore GitHub links
content = content.replace(
new RegExp(tempUrlMarker, 'g'),
'github.com/Draculabo/AntigravityManager',
);
// Cleanup any double replacements
content = content.replace(/GeminiNexus Nexus/g, 'Gemini Nexus');
if (content !== originalContent) {
fs.writeFileSync(fullPath, content, 'utf-8');
console.log(`Updated file content: ${fullPath}`);
}
// Rename file if it contains 'antigravity'
if (file.toLowerCase().includes('antigravity')) {
const newName = file.replace(/antigravity/gi, (match) => {
if (match === 'antigravity') return 'geminiNexus'; // For files like antigravityVersion.ts -> geminiNexusVersion.ts
if (match === 'Antigravity') return 'GeminiNexus';
return 'geminiNexus';
});
const newPath = path.join(dir, newName);
fs.renameSync(fullPath, newPath);
console.log(`Renamed file: ${fullPath} -> ${newPath}`);
}
}
}
}
}
console.log('Starting global deep rename of Antigravity to GeminiNexus...');
walkAndReplace(projectRoot);
console.log('Rename complete.');
-3
View File
@@ -1,3 +0,0 @@
@echo off
set PYTHONPATH=%~dp0
python -m cli.main %*
+75 -24
View File
@@ -41,7 +41,9 @@ function color(code, text) {
}
function header(text) {
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
console.log(
`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`,
);
}
function success(text) {
@@ -145,10 +147,13 @@ function checkWindows() {
for (const vswhere of vswherePaths) {
try {
const output = execSync(`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`, {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
}).trim();
const output = execSync(
`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`,
{
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
},
).trim();
if (output) {
vsFound = true;
@@ -163,10 +168,9 @@ function checkWindows() {
// Check registry fallback
if (!vsFound) {
try {
execSync(
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
{ stdio: 'ignore' },
);
execSync('reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0', {
stdio: 'ignore',
});
vsFound = true;
success('Visual Studio 2022 found (registry)');
} catch {
@@ -198,7 +202,10 @@ function checkMacOS() {
const results = { ok: true, missing: [] };
try {
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
const xcodePath = execSync('xcode-select -p', {
stdio: ['ignore', 'pipe', 'ignore'],
encoding: 'utf8',
}).trim();
if (xcodePath) {
success(`Xcode CLT found at: ${xcodePath}`);
}
@@ -323,14 +330,26 @@ async function installWindows() {
// Check if winget is available
if (!commandExists('winget')) {
console.log('');
console.log(color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'));
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
console.log(
color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'),
);
console.log(
color(
CYAN,
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
),
);
console.log('');
console.log(' During installation, select:');
console.log(color(BOLD, ' "Desktop development with C++" workload'));
console.log('');
console.log(' Or install via PowerShell (as Administrator):');
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
console.log(
color(
DIM,
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
),
);
return false;
}
@@ -398,7 +417,9 @@ async function installLinux() {
}
const status = await runCommand('sudo', [
'apt-get', 'install', '-y',
'apt-get',
'install',
'-y',
'build-essential',
'python3',
'libsecret-1-dev',
@@ -421,7 +442,10 @@ async function installLinux() {
}
const status = await runCommand('sudo', [
'pacman', '-S', '--needed', '--noconfirm',
'pacman',
'-S',
'--needed',
'--noconfirm',
'base-devel',
'python',
'libsecret',
@@ -443,7 +467,9 @@ async function installLinux() {
}
const status = await runCommand('sudo', [
'dnf', 'install', '-y',
'dnf',
'install',
'-y',
'gcc-c++',
'make',
'python3',
@@ -471,7 +497,9 @@ async function installLinux() {
console.log(' - libsecret development headers (for keytar)');
console.log(' - pkg-config');
console.log('');
console.log(' Install them using your distribution package manager, then run npm install again.');
console.log(
' Install them using your distribution package manager, then run npm install again.',
);
return false;
}
@@ -520,7 +548,9 @@ async function main() {
// ── Missing tools ──
console.log('');
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
error(
'Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.',
);
if (checkOnly) {
console.log('');
@@ -528,7 +558,9 @@ async function main() {
warn('Run "npm run setup" to install them automatically, or install manually:');
printManualInstructions(platform, results.missing);
console.log('');
dim('npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.');
dim(
'npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.',
);
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
return;
}
@@ -572,13 +604,23 @@ function printManualInstructions(platform, missing) {
switch (platform) {
case 'win32':
console.log(' 1. Download Visual Studio 2022 Build Tools:');
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
console.log(
color(
CYAN,
' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022',
),
);
console.log('');
console.log(' 2. Run the installer and select:');
console.log(color(BOLD, ' "Desktop development with C++"'));
console.log('');
console.log(' 3. Or install via PowerShell (as Administrator):');
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
console.log(
color(
DIM,
' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"',
),
);
console.log('');
console.log(' 4. Restart your terminal after installation.');
break;
@@ -594,13 +636,22 @@ function printManualInstructions(platform, missing) {
const distro = detectLinuxDistro();
if (distro === 'debian') {
console.log(' Run:');
console.log(color(DIM, ' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config'));
console.log(
color(
DIM,
' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config',
),
);
} else if (distro === 'arch') {
console.log(' Run:');
console.log(color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'));
console.log(
color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'),
);
} else if (distro === 'fedora') {
console.log(' Run:');
console.log(color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'));
console.log(
color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'),
);
} else {
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
}
-54
View File
@@ -1,54 +0,0 @@
import { ipc } from '@/ipc/manager';
import type { DeviceProfile } from '@/types/account';
export function listAccounts() {
return ipc.client.account.listAccounts();
}
export function addAccountSnapshot() {
return ipc.client.account.addAccountSnapshot();
}
export function switchAccount(accountId: string) {
return ipc.client.account.switchAccount({ accountId });
}
export function deleteAccount(accountId: string) {
return ipc.client.account.deleteAccount({ accountId });
}
export function previewGenerateIdentityProfile() {
return ipc.client.account.previewGenerateIdentityProfile();
}
export function getIdentityProfiles(accountId: string) {
return ipc.client.account.getIdentityProfiles({ accountId });
}
export function bindIdentityProfile(accountId: string, mode: 'capture' | 'generate') {
return ipc.client.account.bindIdentityProfile({ accountId, mode });
}
export function bindIdentityProfileWithPayload(accountId: string, profile: DeviceProfile) {
return ipc.client.account.bindIdentityProfileWithPayload({ accountId, profile });
}
export function applyBoundIdentityProfile(accountId: string) {
return ipc.client.account.applyBoundIdentityProfile({ accountId });
}
export function restoreIdentityProfileRevision(accountId: string, versionId: string) {
return ipc.client.account.restoreIdentityProfileRevision({ accountId, versionId });
}
export function deleteIdentityProfileRevision(accountId: string, versionId: string) {
return ipc.client.account.deleteIdentityProfileRevision({ accountId, versionId });
}
export function restoreBaselineProfile(accountId: string) {
return ipc.client.account.restoreBaselineProfile({ accountId });
}
export function openIdentityStorageFolder() {
return ipc.client.account.openIdentityStorageFolder();
}
-14
View File
@@ -1,14 +0,0 @@
import { ipc } from '@/ipc/manager';
import { AccountBackupData, Account } from '@/types/account';
export function backupAccount(account: Account) {
return ipc.client.database.backupAccount(account);
}
export function restoreAccount(backup: AccountBackupData) {
return ipc.client.database.restoreAccount(backup);
}
export function getCurrentAccountInfo() {
return ipc.client.database.getCurrentAccountInfo();
}
-11
View File
@@ -1,11 +0,0 @@
import { ipc } from '../ipc/manager';
export async function minimizeWindow() {
await ipc.client.window.minimizeWindow();
}
export async function maximizeWindow() {
await ipc.client.window.maximizeWindow();
}
export async function closeWindow() {
await ipc.client.window.closeWindow();
}
+4 -2
View File
@@ -114,8 +114,10 @@ export const AccountCard: React.FC<AccountCardProps> = ({
<Card
className={cn(
'relative overflow-hidden transition-all duration-200',
isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
isCurrent
? 'border-primary/50 bg-primary/10'
: 'hover:bg-accent/10 cursor-pointer border-white/5',
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
)}
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -134,7 +134,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
if (!open) {
return;
}
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
setTimeout(() => {
void refreshProfiles();
}, 0);
}, [open, refreshProfiles]);
const runAction = async (key: string, action: () => Promise<void>) => {
if (actionLockRef.current) {
@@ -343,7 +346,10 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
{!showLoadingPlaceholder
? history.map((version) => (
<div key={version.id} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
<div
key={version.id}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3"
>
<div className="mb-2 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-medium">{version.label}</div>
+7 -3
View File
@@ -48,8 +48,12 @@ export function ModelVisibilitySettings() {
// Initialize model visibility and provider groupings from config
useEffect(() => {
if (config?.model_visibility) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
if (config?.model_visibility) {
setTimeout(() => setModelVisibilityMap(config.model_visibility), 0);
}
if (config?.provider_groupings_enabled !== undefined) {
setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0);
}
}, [config?.model_visibility, config?.provider_groupings_enabled]);
// Get all unique models from all accounts
@@ -184,7 +188,7 @@ export function ModelVisibilitySettings() {
return (
<div
key={modelId}
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
className="flex items-center space-x-3 rounded-lg p-2 transition-colors hover:bg-white/[0.03]"
>
<Checkbox
id={`model-${modelId}`}
+26 -96
View File
@@ -3,15 +3,9 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { isProcessRunning, startGeminiNexus, closeGeminiNexus } from '@/actions/process';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { Play, Square, Loader2, Power } from 'lucide-react';
import { Play, Square, Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
interface StatusBarProps {
isCollapsed?: boolean;
}
export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) => {
export const StatusBar: React.FC = () => {
const { t } = useTranslation();
const queryClient = useQueryClient();
@@ -44,112 +38,48 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
};
const isPending = startMutation.isPending || stopMutation.isPending;
// Collapsed View
if (isCollapsed) {
return (
<div className="flex justify-center py-2">
<TooltipProvider>
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
'disabled:opacity-50 disabled:cursor-not-allowed',
isRunning
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
)}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Power className="h-4 w-4" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
<div
className={cn(
'h-1.5 w-1.5 rounded-full',
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
)}
/>
<span>
{isLoading
? t('status.checking')
: isRunning
? t('status.running')
: t('status.stopped')}
</span>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
);
}
// Expanded View
return (
<div
className={cn(
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
isRunning
? 'bg-emerald-500/[0.08]'
: 'bg-white/[0.02]',
<div className="flex items-center gap-2 rounded-md border border-white/[0.06] bg-white/[0.02] px-3 py-2">
{isLoading ? (
<Loader2 className="text-muted-foreground h-3 w-3 animate-spin" />
) : (
<div
className={cn(
'h-2 w-2 rounded-full',
isRunning
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
: 'bg-muted-foreground/50',
)}
/>
)}
>
<div className="flex items-center gap-2.5">
{isLoading ? (
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
) : (
<div
className={cn(
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
isRunning
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
: 'bg-muted-foreground/50',
)}
/>
<span
className={cn(
'text-xs font-medium',
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
)}
<div className="flex flex-col">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
Status
</span>
<span className={cn(
'text-xs font-medium leading-tight',
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
)}>
{isLoading
? t('status.checking')
: isRunning
? t('status.running')
: t('status.stopped')}
</span>
</div>
</div>
>
{isLoading ? '...' : isRunning ? 'Running' : 'Stopped'}
</span>
<Button
variant="ghost"
size="sm"
onClick={handleToggle}
disabled={isLoading || isPending}
className={cn(
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
'h-7 w-7 rounded-md p-0',
isRunning
? 'text-emerald-400 hover:bg-emerald-500/10'
: 'text-muted-foreground hover:bg-white/[0.06]',
)}
>
{isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : isRunning ? (
<Square className="h-3 w-3 fill-current" />
<Square className="h-3.5 w-3.5 fill-current" />
) : (
<Play className="h-3 w-3 fill-current" />
<Play className="h-3.5 w-3.5 fill-current" />
)}
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
<span className="sr-only">{isRunning ? t('action.stop') : t('action.start')}</span>
</Button>
</div>
);
+95
View File
@@ -0,0 +1,95 @@
import { useTranslation } from 'react-i18next';
import { Cloud } from 'lucide-react';
import { CloudAccountCard, CompactCloudAccountCard } from '@/components/CloudAccountCard';
import { type GridLayout } from '@/components/CloudAccountList';
import type { CloudAccount } from '@/types/cloudAccount';
export const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
'2-col': 'grid gap-3 grid-cols-2',
'3-col': 'grid gap-3 grid-cols-3',
list: 'grid gap-3 grid-cols-1',
compact: 'flex flex-col gap-2',
};
interface AccountsGridProps {
accounts: CloudAccount[];
gridLayout: GridLayout;
selectedIds: Set<string>;
onToggleSelection: (id: string, selected: boolean) => void;
onRefresh: (id: string) => void;
onDelete: (id: string) => void;
onSwitch: (id: string) => void;
onManageIdentity: (id: string) => void;
refreshingAccountId?: string;
deletingAccountId?: string;
switchingAccountId?: string;
isRefreshPending: boolean;
isDeletePending: boolean;
isSwitchPending: boolean;
}
export function AccountsGrid(props: AccountsGridProps) {
const { t } = useTranslation();
const {
accounts,
gridLayout,
selectedIds,
onToggleSelection,
onRefresh,
onDelete,
onSwitch,
onManageIdentity,
refreshingAccountId,
deletingAccountId,
switchingAccountId,
isRefreshPending,
isDeletePending,
isSwitchPending,
} = props;
return (
<div className={GRID_LAYOUT_CLASSES[gridLayout]}>
{accounts.map((account) =>
gridLayout === 'compact' ? (
<CompactCloudAccountCard
key={account.id}
account={account}
onRefresh={onRefresh}
onDelete={onDelete}
onSwitch={onSwitch}
onManageIdentity={onManageIdentity}
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
isDeleting={isDeletePending && deletingAccountId === account.id}
isSwitching={isSwitchPending && switchingAccountId === account.id}
/>
) : (
<CloudAccountCard
key={account.id}
account={account}
onRefresh={onRefresh}
onDelete={onDelete}
onSwitch={onSwitch}
onManageIdentity={onManageIdentity}
isSelected={selectedIds.has(account.id)}
onToggleSelection={onToggleSelection}
isRefreshing={isRefreshPending && refreshingAccountId === account.id}
isDeleting={isDeletePending && deletingAccountId === account.id}
isSwitching={isSwitchPending && switchingAccountId === account.id}
/>
),
)}
{accounts.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center rounded-xl border border-dashed border-white/[0.08] bg-white/[0.01] py-20 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-white/[0.04]">
<Cloud className="text-muted-foreground/40 h-8 w-8" />
</div>
<div className="mt-4 text-sm font-medium">{t('cloud.list.noAccounts')}</div>
<p className="text-muted-foreground mt-1 max-w-xs text-xs leading-relaxed">
{t('cloud.description')}
</p>
</div>
)}
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
import { useTranslation } from 'react-i18next';
import { clampQuotaPercentage, getQuotaStatus, type QuotaStatus } from '@/utils/quota-display';
interface AccountsHeaderProps {
totalAccounts: number;
activeAccounts: number;
rateLimitedAccounts: number;
overallQuotaPercentage: number | null;
overallQuotaStatus: QuotaStatus | null;
}
const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'bg-emerald-500',
medium: 'bg-amber-500',
low: 'bg-rose-500',
};
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
high: 'text-emerald-400',
medium: 'text-amber-400',
low: 'text-rose-400',
};
export function AccountsHeader({
totalAccounts,
activeAccounts,
rateLimitedAccounts,
overallQuotaPercentage,
overallQuotaStatus,
}: AccountsHeaderProps) {
const { t } = useTranslation();
const quotaStatus =
overallQuotaStatus ??
(overallQuotaPercentage === null ? null : getQuotaStatus(overallQuotaPercentage));
return (
<div className="mb-8">
<div className="flex flex-col gap-6 md:flex-row md:items-end md:justify-between">
{/* Title Area */}
<div className="flex flex-col gap-1.5">
<h2 className="text-foreground text-2xl font-semibold tracking-tight">
{t('cloud.title')}
</h2>
<p className="text-muted-foreground max-w-xl text-sm leading-relaxed">
{t('cloud.description')}
</p>
</div>
{/* Stats Row */}
<div className="flex flex-wrap items-center gap-3">
{/* Total & Active combined stat */}
<div className="flex min-w-[120px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
<div className="flex items-baseline gap-2">
<span className="text-foreground text-2xl font-bold tracking-tight">
{totalAccounts}
</span>
<span className="text-sm font-medium text-emerald-400">{activeAccounts} active</span>
</div>
<span className="text-muted-foreground/70 mt-0.5 text-xs font-medium tracking-wider uppercase">
{t('cloud.card.actions')}
</span>
</div>
{/* Rate Limited stat (only if > 0) */}
{rateLimitedAccounts > 0 && (
<div className="flex min-w-[100px] flex-col justify-center rounded-xl border border-rose-500/10 bg-rose-500/[0.02] px-4 py-3">
<span className="text-2xl font-bold tracking-tight text-rose-400">
{rateLimitedAccounts}
</span>
<span className="mt-0.5 text-xs font-medium tracking-wider text-rose-400/60 uppercase">
{t('cloud.card.rateLimited')}
</span>
</div>
)}
{/* Global Quota */}
{overallQuotaPercentage !== null && quotaStatus && (
<div className="flex min-w-[140px] flex-col justify-center rounded-xl border border-white/[0.04] bg-white/[0.01] px-4 py-3">
<div className="flex items-baseline gap-1">
<span
className={`text-2xl font-bold tracking-tight ${GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
>
{overallQuotaPercentage}%
</span>
</div>
<div className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className={`h-full rounded-full transition-all duration-700 ease-in-out ${GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS[quotaStatus]}`}
style={{ width: `${clampQuotaPercentage(overallQuotaPercentage)}%` }}
/>
</div>
<span className="text-muted-foreground/70 mt-1.5 text-xs font-medium tracking-wider uppercase">
{t('cloud.globalQuota')}
</span>
</div>
)}
</div>
</div>
</div>
);
}
+287
View File
@@ -0,0 +1,287 @@
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')}
aria-label={t('cloud.layout.auto')}
aria-pressed={gridLayout === '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')}
aria-label={t('cloud.layout.twoCol')}
aria-pressed={gridLayout === '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')}
aria-label={t('cloud.layout.threeCol')}
aria-pressed={gridLayout === '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')}
aria-label={t('cloud.layout.list')}
aria-pressed={gridLayout === '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')}
aria-label={t('cloud.layout.compact')}
aria-pressed={gridLayout === '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]"
aria-label={t('common.openMenu')}
>
<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,49 @@
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}
aria-label={t('cloud.batch.clear')}
>
<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>
</>
);
}
-87
View File
@@ -1,87 +0,0 @@
import { getPlatform } from '@/actions/app';
import { closeWindow, maximizeWindow, minimizeWindow } from '@/actions/window';
import { logger } from '@/utils/logger';
import { type ReactNode, useEffect, useState } from 'react';
interface DragWindowRegionProps {
title?: ReactNode;
}
export default function DragWindowRegion({ title }: DragWindowRegionProps) {
const [platform, setPlatform] = useState<string | null>(null);
useEffect(() => {
let active = true;
getPlatform()
.then((value: any) => {
if (!active) {
return;
}
setPlatform(value as string);
})
.catch((error: any) => {
logger.error('Failed to detect platform', error);
});
return () => {
active = false;
};
}, []);
const isMacOS = platform === 'darwin';
return (
<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">
{title}
</div>
)}
{isMacOS && (
<div className="flex flex-1 p-2">
{/* Maintain the same height but do not display content */}
</div>
)}
</div>
{!isMacOS && <WindowButtons />}
</div>
);
}
function WindowButtons() {
return (
<div className="flex">
<button
title="Minimize"
type="button"
className="p-2 hover:bg-slate-300"
onClick={minimizeWindow}
>
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
<rect fill="currentColor" width="10" height="1" x="1" y="6"></rect>
</svg>
</button>
<button
title="Maximize"
type="button"
className="p-2 hover:bg-slate-300"
onClick={maximizeWindow}
>
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
<rect width="9" height="9" x="1.5" y="1.5" fill="none" stroke="currentColor"></rect>
</svg>
</button>
<button type="button" title="Close" className="p-2 hover:bg-red-300" onClick={closeWindow}>
<svg aria-hidden="true" role="img" width="12" height="12" viewBox="0 0 12 12">
<polygon
fill="currentColor"
fillRule="evenodd"
points="11 1.576 6.583 6 11 10.424 10.424 11 6 6.583 1.576 11 1 10.424 5.417 6 1 1.576 1.576 1 6 5.417 10.424 1"
></polygon>
</svg>
</button>
</div>
);
}
-25
View File
@@ -1,25 +0,0 @@
import { Link } from '@tanstack/react-router';
import {
NavigationMenu as NavigationMenuBase,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
navigationMenuTriggerStyle,
} from '@/components/ui/navigation-menu';
import { useTranslation } from 'react-i18next';
export default function NavigationMenu() {
const { t } = useTranslation();
return (
<NavigationMenuBase className="text-muted-foreground px-2">
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuLink asChild className={navigationMenuTriggerStyle()}>
<Link to="/">{t('titleHomePage')}</Link>
</NavigationMenuLink>
</NavigationMenuItem>
</NavigationMenuList>
</NavigationMenuBase>
);
}
-39
View File
@@ -1,39 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { MonitorTab } from './advanced/MonitorTab';
import { ToolsTab } from './advanced/ToolsTab';
import { ReplayTab } from './advanced/ReplayTab';
import { CapabilitiesTab } from './advanced/CapabilitiesTab';
import type { ProxyConfig } from '@/types/config';
interface ProxyAdvancedTabProps {
proxyConfig: ProxyConfig;
}
export const ProxyAdvancedTab: React.FC<ProxyAdvancedTabProps> = () => {
const { t } = useTranslation();
return (
<Tabs defaultValue="monitor" className="space-y-5">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="monitor">{t('proxy.advanced.monitor')}</TabsTrigger>
<TabsTrigger value="tools">{t('proxy.advanced.tabs.tools')}</TabsTrigger>
<TabsTrigger value="replay">{t('proxy.advanced.tabs.replay')}</TabsTrigger>
<TabsTrigger value="capabilities">{t('proxy.advanced.tabs.capabilities')}</TabsTrigger>
</TabsList>
<TabsContent value="monitor">
<MonitorTab />
</TabsContent>
<TabsContent value="tools">
<ToolsTab />
</TabsContent>
<TabsContent value="replay">
<ReplayTab />
</TabsContent>
<TabsContent value="capabilities">
<CapabilitiesTab />
</TabsContent>
</Tabs>
);
};
@@ -98,7 +98,7 @@ export const CapabilitiesTab: React.FC = () => {
<h3 className="text-base font-semibold">
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
</h3>
<p className="text-[13px] text-muted-foreground">
<p className="text-muted-foreground text-[13px]">
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
</p>
</div>
@@ -113,7 +113,7 @@ export const CapabilitiesTab: React.FC = () => {
{/* Content */}
{isLoading ? (
<div className="flex h-40 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
<Loader2 className="text-muted-foreground h-5 w-5 animate-spin" />
</div>
) : (
<div className="space-y-8">
@@ -121,11 +121,11 @@ export const CapabilitiesTab: React.FC = () => {
<div key={provider} className="space-y-3">
{/* Provider header */}
<div className="flex items-center gap-3">
<h4 className="text-[13px] font-semibold tracking-wide uppercase text-muted-foreground">
<h4 className="text-muted-foreground text-[13px] font-semibold tracking-wide uppercase">
{provider}
</h4>
<div className="h-px flex-1 bg-white/[0.06]" />
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
<span className="text-muted-foreground/60 text-[11px] tabular-nums">
{groupedModels[provider].length}
</span>
</div>
@@ -140,23 +140,23 @@ export const CapabilitiesTab: React.FC = () => {
return (
<div
key={model.id}
className="group flex flex-col rounded-xl border border-white/[0.06] bg-card p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
className="group bg-card flex flex-col rounded-xl border border-white/[0.06] p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
>
{/* Model header */}
<div className="mb-3 space-y-1">
<div className="flex items-start justify-between gap-2">
<h5
className="text-[13px] font-semibold leading-tight truncate"
className="truncate text-[13px] leading-tight font-semibold"
title={model.displayName || model.id}
>
{model.displayName || model.id}
</h5>
<span className="shrink-0 text-[10px] font-medium text-muted-foreground/60 rounded-md bg-white/[0.04] px-1.5 py-0.5">
<span className="text-muted-foreground/60 shrink-0 rounded-md bg-white/[0.04] px-1.5 py-0.5 text-[10px] font-medium">
{enabledCount}/{totalCount}
</span>
</div>
<p
className="font-mono text-[11px] text-muted-foreground truncate"
className="text-muted-foreground truncate font-mono text-[11px]"
title={model.id}
>
{model.id}
@@ -164,7 +164,7 @@ export const CapabilitiesTab: React.FC = () => {
</div>
{/* Capability pills */}
<div className="flex flex-wrap gap-1.5 mb-3">
<div className="mb-3 flex flex-wrap gap-1.5">
{Object.entries(model.capabilities).map(([key, supported]) => {
const iconKey = key as keyof typeof CAPABILITY_ICONS;
const Icon = CAPABILITY_ICONS[iconKey];
@@ -174,8 +174,8 @@ export const CapabilitiesTab: React.FC = () => {
key={key}
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
supported
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/15'
: 'bg-white/[0.02] text-muted-foreground/50 border border-white/[0.04]'
? 'border border-emerald-500/15 bg-emerald-500/10 text-emerald-400'
: 'text-muted-foreground/50 border border-white/[0.04] bg-white/[0.02]'
}`}
title={label}
>
@@ -190,17 +190,17 @@ export const CapabilitiesTab: React.FC = () => {
{hasLimits && (
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
{model.limits.maxTokens != null && (
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
CTX {formatTokenCount(model.limits.maxTokens)}
</span>
)}
{model.limits.maxOutputTokens != null && (
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
<span className="text-muted-foreground inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px]">
OUT {formatTokenCount(model.limits.maxOutputTokens)}
</span>
)}
{model.limits.thinkingBudget != null && (
<span className="inline-flex items-center rounded-md bg-blue-500/10 border border-blue-500/15 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
<span className="inline-flex items-center rounded-md border border-blue-500/15 bg-blue-500/10 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
THINK {formatTokenCount(model.limits.thinkingBudget)}
</span>
)}
@@ -215,12 +215,10 @@ export const CapabilitiesTab: React.FC = () => {
{models.length === 0 && (
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/[0.1] py-16 text-center">
<Layers className="h-8 w-8 text-muted-foreground/30" />
<Layers className="text-muted-foreground/30 h-8 w-8" />
<div className="space-y-1">
<p className="text-[13px] font-medium">No models found</p>
<p className="text-xs text-muted-foreground">
Check your provider configurations
</p>
<p className="text-muted-foreground text-xs">Check your provider configurations</p>
</div>
</div>
)}
+17 -9
View File
@@ -54,7 +54,8 @@ export const ReplayTab: React.FC = () => {
onError: (error) => {
toast({
title: t('proxy.advanced.replay.error'),
description: error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
description:
error instanceof Error ? error.message : t('proxy.advanced.replay.unknownError'),
variant: 'destructive',
});
setSelectedRequestId(null);
@@ -76,7 +77,10 @@ export const ReplayTab: React.FC = () => {
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
if (status >= 200 && status < 300) {
return (
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
<Badge
variant="default"
className="border-emerald-500/20 bg-emerald-500/15 text-emerald-400 hover:bg-emerald-500/20"
>
<CheckCircle className="mr-1 h-3 w-3" />
{status}
</Badge>
@@ -99,7 +103,9 @@ export const ReplayTab: React.FC = () => {
<div className="flex items-center justify-between">
<div>
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
<p className="text-muted-foreground text-[13px]">
{t('proxy.advanced.replay.description')}
</p>
</div>
<Button
variant="outline"
@@ -113,10 +119,12 @@ export const ReplayTab: React.FC = () => {
</div>
{/* Requests List */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="space-y-1 border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
<p className="text-xs text-muted-foreground">{t('proxy.advanced.replay.clickToReplay')}</p>
<p className="text-muted-foreground text-xs">
{t('proxy.advanced.replay.clickToReplay')}
</p>
</div>
<div className="p-5">
{requestsLoading ? (
@@ -125,8 +133,8 @@ export const ReplayTab: React.FC = () => {
</div>
) : requests.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-white/[0.1]">
<Clock className="h-6 w-6 text-muted-foreground/30" />
<p className="text-[13px] text-muted-foreground">
<Clock className="text-muted-foreground/30 h-6 w-6" />
<p className="text-muted-foreground text-[13px]">
{t('proxy.advanced.replay.noRequests')}
</p>
</div>
@@ -143,7 +151,7 @@ export const ReplayTab: React.FC = () => {
<span className="text-[13px] font-medium">
{request.method} {request.endpoint}
</span>
<span className="text-xs text-muted-foreground">
<span className="text-muted-foreground text-xs">
{formatTimestamp(request.timestamp)}
{request.duration && ` · ${request.duration}ms`}
</span>
+81 -29
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery, useQueries } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
@@ -6,17 +6,42 @@ import { ipc } from '@/ipc/manager';
import { Button } from '@/components/ui/button';
import { useToast } from '@/components/ui/use-toast';
import { StatCard } from '@/components/usage/StatCard';
import { Loader2, Copy, CheckCircle, ShieldAlert, XCircle, Terminal, Code2, Braces } from 'lucide-react';
import {
Loader2,
Copy,
CheckCircle,
ShieldAlert,
XCircle,
Terminal,
Code2,
Braces,
Sparkles,
} from 'lucide-react';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
const IDE_CONFIGS = [
{ key: 'opencode', label: 'OpenCode', icon: Sparkles },
{ key: 'vscode', label: 'VS Code', icon: Code2 },
{ key: 'cursor', label: 'Cursor', icon: Terminal },
{ key: 'claude-code', label: 'Claude Code', icon: Braces },
] as const;
export const ToolsTab: React.FC = () => {
interface ToolsTabProps {
availableModelIds?: string[];
}
export const ToolsTab: React.FC<ToolsTabProps> = ({
availableModelIds = ['gemini-3.1-pro-high'],
}) => {
const { t } = useTranslation();
const { toast } = useToast();
const [selectedModel, setSelectedModel] = useState<string>(availableModelIds[0]);
const { data: parityData, isLoading: parityLoading } = useQuery({
queryKey: ['proxyAdvanced', 'parityCounters'],
@@ -25,16 +50,11 @@ export const ToolsTab: React.FC = () => {
});
const ideQueries = useQueries({
queries: [
{
queryKey: ['proxyAdvanced', 'ideConfig', 'vscode'],
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'vscode' }),
},
{
queryKey: ['proxyAdvanced', 'ideConfig', 'cursor'],
queryFn: () => ipc.client.proxyAdvanced.generateIdeConfig({ ide: 'cursor' }),
},
],
queries: IDE_CONFIGS.map(({ key }) => ({
queryKey: ['proxyAdvanced', 'ideConfig', key, selectedModel],
queryFn: () =>
ipc.client.proxyAdvanced.generateIdeConfig({ ide: key, defaultModel: selectedModel }),
})),
});
const parity = parityData?.success ? parityData.data : undefined;
@@ -44,15 +64,21 @@ export const ToolsTab: React.FC = () => {
toast({ title: t('proxy.advanced.tools.copyConfig') });
};
const getIdeInstructions = (key: string): string => {
const getIdeInstructions = (key: string, index: number): string => {
if (key === 'claude-code') {
return 'export ANTHROPIC_BASE_URL=http://localhost:8045/v1\nexport ANTHROPIC_API_KEY=YOUR_API_KEY';
}
const index = key === 'vscode' ? 0 : 1;
const result = ideQueries[index].data;
const result = ideQueries[index]?.data;
if (result?.success && result.data) {
const d = result.data;
return `${d.instructions}\n${d.proxySetting}: http://localhost:8045\n${d.apiKeySetting}: YOUR_API_KEY`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = result.data as any;
if (d.format === 'json') {
return JSON.stringify(d.content, null, 2);
}
if (d.format === 'env' || d.format === 'shell') {
return typeof d.content === 'string' ? d.content : JSON.stringify(d.content, null, 2);
}
return `${d.instructions || ''}\n${d.proxySetting || 'URL'}: http://localhost:8045\n${d.apiKeySetting || 'Key'}: YOUR_API_KEY`;
}
return '';
};
@@ -87,24 +113,45 @@ export const ToolsTab: React.FC = () => {
</div>
{/* IDE Quick Setup */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
<p className="text-xs text-muted-foreground">Quick configuration for popular IDEs</p>
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="flex items-center justify-between border-b border-white/[0.06] px-5 py-4">
<div className="space-y-1">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
<p className="text-muted-foreground text-xs">Quick configuration for popular IDEs</p>
</div>
<div className="flex items-center gap-2">
<span className="text-muted-foreground text-xs">Default Model:</span>
<Select value={selectedModel} onValueChange={setSelectedModel}>
<SelectTrigger className="h-8 w-[180px] text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{availableModelIds.map((id) => (
<SelectItem key={id} value={id}>
{id}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="p-5 space-y-3">
<div className="space-y-3 p-5">
{isIdeLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : (
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
const instructions = getIdeInstructions(key);
IDE_CONFIGS.map(({ key, label, icon: Icon }, index) => {
const instructions = getIdeInstructions(key, index);
const resultData = ideQueries[index]?.data;
const hasError = resultData && !resultData.success;
const errorMsg = resultData?.error || '';
return (
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
<div className="flex items-center justify-between p-3">
<div className="flex items-center gap-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<Icon className="text-muted-foreground h-4 w-4" />
<span className="text-[13px] font-medium">{label}</span>
</div>
<Button
@@ -112,13 +159,19 @@ export const ToolsTab: React.FC = () => {
size="sm"
className="h-8 text-xs"
onClick={() => copyToClipboard(instructions)}
disabled={!instructions || hasError}
>
<Copy className="mr-1.5 h-3.5 w-3.5" />
{t('proxy.advanced.tools.copyConfig')}
</Button>
</div>
{instructions && (
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
{hasError && (
<div className="bg-card border-t border-white/[0.06] p-3 font-mono text-xs text-amber-500/80">
{errorMsg}
</div>
)}
{instructions && !hasError && (
<pre className="bg-card text-foreground/90 overflow-x-auto border-t border-white/[0.06] p-3 font-mono text-xs whitespace-pre-wrap">
{instructions}
</pre>
)}
@@ -128,7 +181,6 @@ export const ToolsTab: React.FC = () => {
)}
</div>
</div>
</div>
);
};
@@ -1,190 +0,0 @@
import React, { useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
interface TrafficLogEntry {
timestamp: string;
direction: string;
requestId: string;
endpoint: string;
method?: string;
status?: number;
durationMs?: number;
}
interface TrafficLogTableProps {
logs: TrafficLogEntry[];
isLoading: boolean;
}
type SortKey = 'timestamp' | 'direction' | 'endpoint' | 'method' | 'status' | 'duration';
type SortDir = 'asc' | 'desc';
function SortIcon({ column, sortKey, sortDir }: { column: SortKey; sortKey: SortKey; sortDir: SortDir }) {
if (sortKey !== column) return <span className="ml-1 inline-block w-3" />;
return sortDir === 'asc' ? (
<ArrowUp className="ml-1 inline h-3 w-3" />
) : (
<ArrowDown className="ml-1 inline h-3 w-3" />
);
}
export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoading }) => {
const { t } = useTranslation();
const [sortKey, setSortKey] = useState<SortKey>('timestamp');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const toggleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir((prev) => (prev === 'asc' ? 'desc' : 'asc'));
} else {
setSortKey(key);
setSortDir('asc');
}
};
const sortedLogs = useMemo(() => {
const dir = sortDir === 'asc' ? 1 : -1;
return [...logs].sort((a, b) => {
switch (sortKey) {
case 'timestamp':
return (new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()) * dir;
case 'direction':
return (a.direction || '').localeCompare(b.direction || '') * dir;
case 'endpoint':
return (a.endpoint || '').localeCompare(b.endpoint || '') * dir;
case 'method':
return (a.method || '').localeCompare(b.method || '') * dir;
case 'status': {
const aStatus = a.status ?? -1;
const bStatus = b.status ?? -1;
return (aStatus - bStatus) * dir;
}
case 'duration': {
const aDur = a.durationMs ?? -1;
const bDur = b.durationMs ?? -1;
return (aDur - bDur) * dir;
}
default:
return 0;
}
});
}, [logs, sortKey, sortDir]);
return (
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">
{t('proxy.advanced.trafficLog.title')}
</h4>
</div>
<div className="p-5">
{isLoading ? (
<div className="flex h-32 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : logs.length === 0 ? (
<div className="py-8 text-center text-[13px] text-muted-foreground">
{t('proxy.advanced.trafficLog.noLogs')}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase text-muted-foreground">
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('timestamp')}
>
{t('proxy.advanced.trafficLog.timestamp')}
<SortIcon column="timestamp" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('direction')}
>
{t('proxy.advanced.trafficLog.direction')}
<SortIcon column="direction" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('endpoint')}
>
{t('proxy.advanced.trafficLog.endpoint')}
<SortIcon column="endpoint" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('method')}
>
{t('proxy.advanced.trafficLog.method')}
<SortIcon column="method" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 pr-4 font-medium select-none"
onClick={() => toggleSort('status')}
>
{t('proxy.advanced.trafficLog.status')}
<SortIcon column="status" sortKey={sortKey} sortDir={sortDir} />
</th>
<th
className="cursor-pointer pb-2 font-medium select-none"
onClick={() => toggleSort('duration')}
>
{t('proxy.advanced.trafficLog.duration')}
<SortIcon column="duration" sortKey={sortKey} sortDir={sortDir} />
</th>
</tr>
</thead>
<tbody>
{sortedLogs.map((log, i) => (
<tr
key={`${log.requestId}-${i}`}
className="border-b border-white/[0.04] last:border-0"
>
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
{new Date(log.timestamp).toLocaleTimeString()}
</td>
<td className="py-2 pr-4">
<Badge
variant="outline"
className={
log.direction === 'inbound'
? 'border-blue-500/30 text-blue-400'
: log.direction === 'outbound'
? 'border-emerald-500/30 text-emerald-400'
: 'border-slate-500/30 text-slate-400'
}
>
{log.direction}
</Badge>
</td>
<td className="py-2 pr-4 font-mono text-xs">{log.endpoint}</td>
<td className="py-2 pr-4 text-xs">{log.method || '—'}</td>
<td className="py-2 pr-4 text-xs">
{log.status != null ? (
<Badge
variant={log.status >= 400 ? 'destructive' : 'secondary'}
className="text-xs"
>
{log.status}
</Badge>
) : (
'—'
)}
</td>
<td className="py-2 text-xs">
{log.durationMs != null ? `${log.durationMs}ms` : '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
+49
View File
@@ -0,0 +1,49 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const alertVariants = cva(
'relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground',
{
variants: {
variant: {
default: 'bg-background text-foreground',
destructive:
'border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive',
},
},
defaultVariants: {
variant: 'default',
},
},
);
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div ref={ref} role="alert" className={cn(alertVariants({ variant }), className)} {...props} />
));
Alert.displayName = 'Alert';
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn('mb-1 leading-none font-medium tracking-tight', className)}
{...props}
/>
),
);
AlertTitle.displayName = 'AlertTitle';
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('text-sm [&_p]:leading-relaxed', className)} {...props} />
));
AlertDescription.displayName = 'AlertDescription';
export { Alert, AlertTitle, AlertDescription };
+2 -4
View File
@@ -9,16 +9,14 @@ const buttonVariants = cva(
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
default: 'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
destructive:
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
outline:
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
ghost:
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
ghost: 'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
link: 'text-accent underline-offset-4 hover:underline',
},
size: {
+1 -1
View File
@@ -26,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('text-sm font-semibold leading-none tracking-tight', className)}
className={cn('text-sm leading-none font-semibold tracking-tight', className)}
{...props}
/>
),
+1 -1
View File
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
<CheckboxPrimitive.Root
ref={ref}
className={cn(
'peer h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background',
'peer ring-offset-background focus-visible:ring-accent/30 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}
+2 -2
View File
@@ -38,7 +38,7 @@ const DialogContent = React.forwardRef<
ref={ref}
className={cn(
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'gap-4 rounded-xl border border-white/[0.08] bg-card p-6 shadow-2xl shadow-black/30',
'bg-card gap-4 rounded-xl border border-white/[0.08] p-6 shadow-2xl shadow-black/30',
'duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
@@ -47,7 +47,7 @@ const DialogContent = React.forwardRef<
{...props}
>
{children}
<DialogPrimitive.Close className="absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/30 disabled:pointer-events-none">
<DialogPrimitive.Close className="focus:ring-ring/30 absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:ring-2 focus:outline-none disabled:pointer-events-none">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
+9 -5
View File
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] outline-none select-none transition-colors focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
inset && 'pl-8',
className,
)}
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
'relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:text-foreground relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
inset && 'pl-8',
className,
)}
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
checked={checked}
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
'focus:text-foreground relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
@@ -154,7 +154,11 @@ const DropdownMenuLabel = React.forwardRef<
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
className={cn(
'text-muted-foreground px-2 py-1.5 text-xs font-medium',
inset && 'pl-8',
className,
)}
{...props}
/>
));
+3 -3
View File
@@ -8,10 +8,10 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
<input
type={type}
className={cn(
'flex h-9 w-full rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-1 text-[13px] text-foreground shadow-none transition-colors duration-200',
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
'bg-muted/50 text-foreground flex h-9 w-full rounded-lg border border-white/[0.08] px-3 py-1 text-[13px] shadow-none transition-colors duration-200',
'file:text-foreground file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
'focus-visible:border-accent/50 focus-visible:ring-accent/20 focus-visible:ring-1 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
-161
View File
@@ -1,161 +0,0 @@
import * as React from 'react';
import * as NavigationMenuPrimitive from '@radix-ui/react-navigation-menu';
import { cva } from 'class-variance-authority';
import { ChevronDownIcon } from 'lucide-react';
import { cn } from '@/lib/utils';
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean;
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
'group/navigation-menu relative flex max-w-max flex-1 items-center justify-center',
className,
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
);
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn('group flex flex-1 list-none items-center justify-center gap-1', className)}
{...props}
/>
);
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn('relative', className)}
{...props}
/>
);
}
const navigationMenuTriggerStyle = cva(
'group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1',
);
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), 'group', className)}
{...props}
>
{children}{' '}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
);
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
'data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto',
'group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none',
className,
)}
{...props}
/>
);
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div className={cn('absolute top-full left-0 isolate z-50 flex justify-center')}>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
'origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]',
className,
)}
{...props}
/>
</div>
);
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
/>
);
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
'data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden',
className,
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
);
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
};
+7 -7
View File
@@ -19,9 +19,9 @@ const SelectTrigger = React.forwardRef<
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-2 text-[13px] text-foreground transition-colors duration-200',
'bg-muted/50 text-foreground flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] px-3 py-2 text-[13px] transition-colors duration-200',
'placeholder:text-muted-foreground',
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
'focus:border-accent/50 focus:ring-accent/20 focus:ring-1 focus:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className,
@@ -72,7 +72,7 @@ const SelectContent = React.forwardRef<
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] bg-popover text-popover-foreground shadow-xl shadow-black/20',
'bg-popover text-popover-foreground relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] shadow-xl shadow-black/20',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
position === 'popper' &&
@@ -104,7 +104,7 @@ const SelectLabel = React.forwardRef<
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
className={cn('text-muted-foreground py-1.5 pr-2 pl-8 text-xs font-medium', className)}
{...props}
/>
));
@@ -117,8 +117,8 @@ const SelectItem = React.forwardRef<
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] outline-none select-none transition-colors',
'focus:bg-white/[0.06] focus:text-foreground',
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none',
'focus:text-foreground focus:bg-white/[0.06]',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
@@ -126,7 +126,7 @@ const SelectItem = React.forwardRef<
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5 text-accent" />
<Check className="text-accent h-3.5 w-3.5" />
</SelectPrimitive.ItemIndicator>
</span>
+1 -6
View File
@@ -1,12 +1,7 @@
import { cn } from '@/lib/utils';
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)}
{...props}
/>
);
return <div className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)} {...props} />;
}
export { Skeleton };
+1 -1
View File
@@ -10,7 +10,7 @@ const Switch = React.forwardRef<
<SwitchPrimitives.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'focus-visible:ring-ring/30 focus-visible:ring-offset-background focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
className,
+4 -4
View File
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
<TabsPrimitive.List
ref={ref}
className={cn(
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg border border-white/[0.04] p-1',
className,
)}
{...props}
@@ -28,8 +28,8 @@ const TabsTrigger = React.forwardRef<
className={cn(
'inline-flex items-center justify-center rounded-md px-3 py-1 text-[13px] font-medium whitespace-nowrap transition-all duration-200',
'text-muted-foreground hover:text-foreground',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-white/[0.08]',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:border data-[state=active]:border-white/[0.08] data-[state=active]:shadow-sm',
'focus-visible:ring-ring/30 focus-visible:ring-2 focus-visible:outline-none',
'disabled:pointer-events-none disabled:opacity-50',
className,
)}
@@ -45,7 +45,7 @@ const TabsContent = React.forwardRef<
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
'animate-in focus-visible:ring-ring/30 mt-3 focus-visible:ring-2 focus-visible:outline-none',
className,
)}
{...props}
-69
View File
@@ -1,69 +0,0 @@
'use client';
import * as React from 'react';
import * as ToggleGroupPrimitive from '@radix-ui/react-toggle-group';
import { type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
import { toggleVariants } from '@/components/ui/toggle';
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
size: 'default',
variant: 'default',
});
function ToggleGroup({
className,
variant,
size,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
className={cn(
'group/toggle-group flex w-fit items-center rounded-md data-[variant=outline]:shadow-xs',
className,
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
);
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> & VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext);
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
'min-w-0 flex-1 shrink-0 rounded-none shadow-none first:rounded-l-md last:rounded-r-md focus:z-10 focus-visible:z-10 data-[variant=outline]:border-l-0 data-[variant=outline]:first:border-l',
className,
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
);
}
export { ToggleGroup, ToggleGroupItem };
-44
View File
@@ -1,44 +0,0 @@
import * as React from 'react';
import * as TogglePrimitive from '@radix-ui/react-toggle';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: 'bg-transparent',
outline:
'border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground',
},
size: {
default: 'h-9 px-2 min-w-9',
sm: 'h-8 px-1.5 min-w-8',
lg: 'h-10 px-2.5 min-w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
);
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> & VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
);
}
export { Toggle, toggleVariants };
+1 -1
View File
@@ -17,7 +17,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-lg border border-white/[0.08] bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg shadow-black/20',
'bg-popover text-popover-foreground z-50 overflow-hidden rounded-lg border border-white/[0.08] px-3 py-1.5 text-xs shadow-lg shadow-black/20',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
+6 -6
View File
@@ -29,10 +29,10 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
children,
}) => {
return (
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
<div className="bg-card flex flex-col rounded-xl border border-white/[0.06]">
<div className="flex flex-col p-5">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
<h3 className="text-foreground text-[13px] font-semibold tracking-tight">{title}</h3>
</div>
<div className="flex-1">
{isLoading ? (
@@ -41,8 +41,8 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
</div>
) : error ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
<AlertTriangle className="h-5 w-5 text-destructive" />
<div className="bg-destructive/10 flex h-10 w-10 items-center justify-center rounded-lg">
<AlertTriangle className="text-destructive h-5 w-5" />
</div>
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
<Button variant="outline" size="sm" onClick={onRetry}>
@@ -53,9 +53,9 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
) : isEmpty ? (
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
<BarChart3 className="text-muted-foreground/60 h-5 w-5" />
</div>
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
<p className="text-foreground text-[13px] font-medium">{emptyTitle}</p>
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
{emptyDescription}
</p>
+129
View File
@@ -0,0 +1,129 @@
import React from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
export interface ModelCostEntry {
model: string;
inputTokens: number;
outputTokens: number;
inputCost: number;
outputCost: number;
totalCost: number;
}
export interface CostReportCardProps {
models: ModelCostEntry[];
totalInputCost: number;
totalOutputCost: number;
totalCost: number;
isLoading: boolean;
}
function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
}
export const CostReportCard: React.FC<CostReportCardProps> = ({
models,
totalInputCost,
totalOutputCost,
totalCost,
isLoading,
}) => {
if (isLoading) {
return (
<Card className="bg-card/50 border-white/[0.06]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
Loading cost data...
</div>
</CardContent>
</Card>
);
}
if (!models.length) {
return (
<Card className="bg-card/50 border-white/[0.06]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
</CardHeader>
<CardContent>
<div className="text-muted-foreground flex items-center justify-center py-6 text-sm">
No usage data for cost calculation
</div>
</CardContent>
</Card>
);
}
return (
<Card className="bg-card/50 border-white/[0.06]">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Report</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="text-muted-foreground border-b border-white/[0.06]">
<th className="py-2 pr-4 font-medium">Model</th>
<th className="py-2 pr-4 text-right font-medium">Input Tokens</th>
<th className="py-2 pr-4 text-right font-medium">Input Cost</th>
<th className="py-2 pr-4 text-right font-medium">Output Tokens</th>
<th className="py-2 pr-4 text-right font-medium">Output Cost</th>
<th className="py-2 text-right font-medium">Total</th>
</tr>
</thead>
<tbody className="divide-y divide-white/[0.04]">
{models.map((m) => (
<tr key={m.model} className="hover:bg-white/[0.02]">
<td className="text-foreground py-2 pr-4">{m.model}</td>
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
{formatNumber(m.inputTokens)}
</td>
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
${m.inputCost.toFixed(2)}
</td>
<td className="text-muted-foreground py-2 pr-4 text-right font-mono">
{formatNumber(m.outputTokens)}
</td>
<td className="py-2 pr-4 text-right font-mono text-emerald-400">
${m.outputCost.toFixed(2)}
</td>
<td className="py-2 text-right font-mono font-medium text-emerald-500">
${m.totalCost.toFixed(2)}
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-white/[0.08] font-medium">
<td className="text-foreground py-3 pr-4">Total</td>
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
{formatNumber(models.reduce((sum, m) => sum + m.inputTokens, 0))}
</td>
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
${totalInputCost.toFixed(2)}
</td>
<td className="text-muted-foreground py-3 pr-4 text-right font-mono">
{formatNumber(models.reduce((sum, m) => sum + m.outputTokens, 0))}
</td>
<td className="py-3 pr-4 text-right font-mono text-emerald-400">
${totalOutputCost.toFixed(2)}
</td>
<td className="py-3 text-right font-mono font-bold text-emerald-500">
${totalCost.toFixed(2)}
</td>
</tr>
</tfoot>
</table>
</div>
</CardContent>
</Card>
);
};
+171
View File
@@ -0,0 +1,171 @@
import { useMemo } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { TrendingUp, Clock, Calendar, CalendarDays } from 'lucide-react';
interface UsageBucket {
totalTokens: number;
requests: number;
}
interface ForecastCardProps {
/** Hourly buckets for the selected period (always fetched for all ranges). */
hourlyData?: UsageBucket[];
/** Daily buckets for the selected period. */
dailyData?: UsageBucket[];
range: '24h' | '7d' | '30d';
}
function formatNumber(n: number): string {
if (n >= 1_000_000) {
return `${(n / 1_000_000).toFixed(1)}M`;
}
if (n >= 1_000) {
return `${(n / 1_000).toFixed(1)}K`;
}
return n.toLocaleString(undefined, { maximumFractionDigits: 0 });
}
/**
* Usage Averages Card.
*
* Calculates REAL averages over the time the user actually used the proxy,
* not projections over the entire selected window.
*
* Logic:
* - totalTokens = sum of all buckets in the period
* - activeHours = number of hourly buckets with totalTokens > 0
* - activeDays = number of daily buckets with totalTokens > 0
* - avgPerHour = totalTokens / activeHours (real average, no padding)
* - avgPerDay = totalTokens / activeDays (real average, no padding)
*
* For the 24h range, avgPerDay equals totalTokens (since it's a single day).
* For 7d/30d, avgPerDay = totalTokens / activeDays.
*
* The card title says "Usage Averages" (not "Forecast") because these are
* real historical averages, not projections.
*/
export function ForecastCard({ hourlyData, dailyData, range }: ForecastCardProps) {
const stats = useMemo(() => {
const hours = hourlyData ?? [];
const days = dailyData ?? [];
const totalTokens =
hours.length > 0
? hours.reduce((acc, d) => acc + d.totalTokens, 0)
: days.reduce((acc, d) => acc + d.totalTokens, 0);
const totalRequests =
hours.length > 0
? hours.reduce((acc, d) => acc + d.requests, 0)
: days.reduce((acc, d) => acc + d.requests, 0);
// Active hours = hourly buckets where there was actual usage
const activeHours = hours.filter((d) => d.totalTokens > 0).length;
// Active days = daily buckets where there was actual usage
const activeDays = days.filter((d) => d.totalTokens > 0).length;
// Real average per active hour
const avgPerHour = activeHours > 0 ? totalTokens / activeHours : 0;
// Real average per active day
const avgPerDay = activeDays > 0 ? totalTokens / activeDays : 0;
// Average tokens per request
const avgPerRequest = totalRequests > 0 ? totalTokens / totalRequests : 0;
return {
totalTokens,
totalRequests,
activeHours,
activeDays,
avgPerHour,
avgPerDay,
avgPerRequest,
};
}, [hourlyData, dailyData]);
const rangeLabel = range === '24h' ? 'Last 24h' : range === '7d' ? 'Last 7 days' : 'Last 30 days';
return (
<Card className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<CardHeader className="p-4 pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
Usage Averages
</CardTitle>
<span className="text-muted-foreground/70 text-[10px]">{rangeLabel}</span>
</div>
</CardHeader>
<CardContent className="p-4 pt-0">
<div className="mt-2 space-y-3">
{/* Total consumed in period */}
<div>
<div className="mb-0.5 flex items-center gap-1.5">
<TrendingUp className="h-3 w-3 text-emerald-400" />
<span className="text-muted-foreground text-xs">Total in period</span>
</div>
<div className="text-foreground font-mono text-lg font-semibold">
{formatNumber(stats.totalTokens)}{' '}
<span className="text-muted-foreground font-sans text-xs font-normal">tokens</span>
</div>
<div className="text-muted-foreground/70 mt-0.5 text-[10px]">
{stats.activeHours > 0 &&
`${stats.activeHours} active hour${stats.activeHours !== 1 ? 's' : ''}`}
{stats.activeHours > 0 && stats.activeDays > 0 && ' · '}
{stats.activeDays > 0 &&
`${stats.activeDays} active day${stats.activeDays !== 1 ? 's' : ''}`}
{stats.totalRequests > 0 && ` · ${stats.totalRequests.toLocaleString()} requests`}
</div>
</div>
<div className="h-px bg-white/[0.06]" />
{/* Average per active hour */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Clock className="h-3 w-3 text-blue-400" />
<span className="text-muted-foreground text-xs">Avg / active hour</span>
</div>
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
{formatNumber(stats.avgPerHour)}
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
tokens/h
</span>
</div>
</div>
{/* Average per active day */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<Calendar className="h-3 w-3 text-violet-400" />
<span className="text-muted-foreground text-xs">Avg / active day</span>
</div>
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
{formatNumber(stats.avgPerDay)}
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
tokens/d
</span>
</div>
</div>
{/* Average per request */}
{stats.totalRequests > 0 && (
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
<CalendarDays className="h-3 w-3 text-amber-400" />
<span className="text-muted-foreground text-xs">Avg / request</span>
</div>
<div className="text-foreground font-mono text-sm font-semibold tabular-nums">
{formatNumber(stats.avgPerRequest)}
<span className="text-muted-foreground ml-1 font-sans text-[10px] font-normal">
tokens/req
</span>
</div>
</div>
)}
</div>
</CardContent>
</Card>
);
}
+284
View File
@@ -0,0 +1,284 @@
import React, { useState } 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 { 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);
const [pricingSource, setPricingSource] = useState<{
open: boolean;
customPricing: typeof customPricing;
}>({ open, customPricing });
// Re-seed the editable copy when the dialog opens or the source pricing changes.
// Adjusting state during render replaces the previous synchronous setState inside
// an effect (https://react.dev/learn/you-might-not-need-an-effect).
if (pricingSource.open !== open || pricingSource.customPricing !== customPricing) {
setPricingSource({ open, customPricing });
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);
}
}
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"
aria-label={`Reset ${model} pricing to default`}
>
<RotateCcw className="h-3 w-3" />
</Button>
)}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<DialogFooter className="mt-4">
<Button variant="ghost" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSave}>Save Changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+59
View File
@@ -0,0 +1,59 @@
import clsx from 'clsx';
interface ModelQuotaBarProps {
modelName: string;
usedTokens: number;
limitTokens?: number;
}
export function ModelQuotaBar({ modelName, usedTokens, limitTokens }: ModelQuotaBarProps) {
const formatNumber = (n: number) => {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
return n.toLocaleString();
};
const usagePercentage = limitTokens ? (usedTokens / limitTokens) * 100 : 0;
const displayPercentage = limitTokens ? Math.min(Math.round(usagePercentage), 100) : null;
const barPercentage = limitTokens ? Math.min(usagePercentage, 100) : 100;
let colorClass = 'bg-slate-500'; // Default if no limit
if (limitTokens) {
if (usagePercentage < 80) colorClass = 'bg-emerald-500';
else if (usagePercentage < 90) colorClass = 'bg-amber-500';
else colorClass = 'bg-rose-500';
}
return (
<div className="group mb-3">
<div className="mb-1 flex items-center justify-between">
<span className="text-muted-foreground group-hover:text-foreground max-w-[140px] truncate text-xs transition-colors">
{modelName}
</span>
<div className="flex items-center gap-2">
<span className="text-foreground font-mono text-[11px] font-medium tabular-nums">
{formatNumber(usedTokens)}
{limitTokens ? ` / ${formatNumber(limitTokens)}` : ''}
</span>
{displayPercentage !== null && (
<span
className={clsx('w-8 text-right text-[10px] font-semibold', {
'text-emerald-400': usagePercentage < 80,
'text-amber-400': usagePercentage >= 80 && usagePercentage < 90,
'text-rose-400': usagePercentage >= 90,
})}
>
{displayPercentage}%
</span>
)}
</div>
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
<div
className={clsx('h-full rounded-full transition-all duration-500', colorClass)}
style={{ width: `${barPercentage}%`, opacity: 0.8 }}
/>
</div>
</div>
);
}
@@ -1,206 +1,203 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { StatCard } from '@/components/usage/StatCard';
import { TrafficLogTable } from './TrafficLogTable';
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
export const MonitorTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const [direction, setDirection] = useState<string>('all');
const { data: metricsData, isLoading: metricsLoading } = useQuery({
queryKey: ['proxyAdvanced', 'metrics'],
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
refetchInterval: 30000,
});
const { data: cbData, isLoading: cbLoading } = useQuery({
queryKey: ['proxyAdvanced', 'circuitBreaker'],
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
refetchInterval: 30000,
});
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['proxyAdvanced', 'logs', direction],
queryFn: () =>
ipc.client.proxyAdvanced.getRecentTrafficLogs({
limit: 50,
direction: direction === 'all' ? undefined : direction,
}),
refetchInterval: 30000,
});
const { data: statsData } = useQuery({
queryKey: ['proxyAdvanced', 'stats'],
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
refetchInterval: 30000,
});
const metrics = metricsData?.success ? metricsData.data : undefined;
const circuitBreakers = cbData?.success ? cbData.data : undefined;
const logs = logsData?.success ? logsData.data : [];
const stats = statsData?.success ? statsData.data : undefined;
const handleExport = async (format: 'json' | 'csv') => {
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
if (result.success && result.data) {
navigator.clipboard.writeText(result.data);
toast({ title: t('proxy.advanced.tools.copyConfig') });
} else {
toast({ title: t('common.error'), description: result.error });
}
};
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
if (state === 'closed') return 'default';
if (state === 'open') return 'destructive';
return 'secondary';
};
return (
<div className="space-y-5">
{/* Metrics */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('proxy.advanced.metrics.requestsPerMinute')}
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
icon={Zap}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.avgLatency')}
value={metrics ? Math.round(metrics.avgLatency) : 0}
icon={Clock}
isLoading={metricsLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.metrics.errorRate')}
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
icon={AlertTriangle}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.activeConnections')}
value={metrics?.activeConnections ?? 0}
icon={Network}
isLoading={metricsLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.metrics.cacheStatus')}
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
icon={HardDrive}
isLoading={metricsLoading}
accent="purple"
/>
</div>
{/* Circuit Breakers */}
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">
{t('proxy.advanced.circuitBreaker.title')}
</h4>
</div>
<div className="p-5">
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-[13px] text-muted-foreground">No data</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(circuitBreakers.states).map(([api, state]) => (
<div key={api} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium">{api}</span>
<Badge variant={getCircuitBadgeVariant(state.state)}>
{state.state}
</Badge>
</div>
<div className="mt-2 text-xs text-muted-foreground">
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
</div>
{state.lastFailure && (
<div className="text-xs text-muted-foreground">
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
{new Date(state.lastFailure).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* Traffic Logs */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h3 className="text-[13px] font-semibold">
{t('proxy.advanced.trafficLog.title')}
</h3>
{stats && (
<span className="text-xs text-muted-foreground">
Total: {stats.total}
</span>
)}
</div>
<div className="flex items-center gap-2">
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t('proxy.advanced.trafficLog.all')}
</SelectItem>
<SelectItem value="inbound">
{t('proxy.advanced.trafficLog.inbound')}
</SelectItem>
<SelectItem value="outbound">
{t('proxy.advanced.trafficLog.outbound')}
</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('json')}
>
{t('proxy.advanced.trafficLog.exportJson')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('csv')}
>
{t('proxy.advanced.trafficLog.exportCsv')}
</Button>
</div>
</div>
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
</div>
</div>
);
};
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useQuery } from '@tanstack/react-query';
import { ipc } from '@/ipc/manager';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { useToast } from '@/components/ui/use-toast';
import { StatCard } from '@/components/usage/StatCard';
import { TrafficLogTable } from './TrafficLogTable';
import { Loader2, Zap, Clock, AlertTriangle, Network, HardDrive } from 'lucide-react';
export const MonitorTab: React.FC = () => {
const { t } = useTranslation();
const { toast } = useToast();
const [direction, setDirection] = useState<string>('all');
const { data: metricsData, isLoading: metricsLoading } = useQuery({
queryKey: ['proxyAdvanced', 'metrics'],
queryFn: () => ipc.client.proxyAdvanced.getProxyMetrics(),
refetchInterval: 30000,
});
const { data: cbData, isLoading: cbLoading } = useQuery({
queryKey: ['proxyAdvanced', 'circuitBreaker'],
queryFn: () => ipc.client.proxyAdvanced.getCircuitBreakerStatus(),
refetchInterval: 30000,
});
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['proxyAdvanced', 'logs', direction],
queryFn: () =>
ipc.client.proxyAdvanced.getRecentTrafficLogs({
limit: 50,
direction: direction === 'all' ? undefined : direction,
}),
refetchInterval: 30000,
});
const { data: statsData } = useQuery({
queryKey: ['proxyAdvanced', 'stats'],
queryFn: () => ipc.client.proxyAdvanced.getTrafficLogStats(),
refetchInterval: 30000,
});
const metrics = metricsData?.success ? metricsData.data : undefined;
const circuitBreakers = cbData?.success ? cbData.data : undefined;
const logs = logsData?.success
? (logsData.data || []).map((log) => ({
...log,
model: log.metadata?.model as string | undefined,
tokensPrompt: log.metadata?.tokensPrompt as number | undefined,
tokensCompletion: log.metadata?.tokensCompletion as number | undefined,
tokensTotal:
((log.metadata?.tokensPrompt as number) || 0) +
((log.metadata?.tokensCompletion as number) || 0) || undefined,
}))
: [];
const stats = statsData?.success ? statsData.data : undefined;
const handleExport = async (format: 'json' | 'csv') => {
const result = await ipc.client.proxyAdvanced.exportTrafficLogs({ format });
if (result.success && result.data) {
navigator.clipboard.writeText(result.data);
toast({ title: t('proxy.advanced.tools.copyConfig') });
} else {
toast({ title: t('common.error'), description: result.error });
}
};
const getCircuitBadgeVariant = (state: string): React.ComponentProps<typeof Badge>['variant'] => {
if (state === 'closed') return 'default';
if (state === 'open') return 'destructive';
return 'secondary';
};
return (
<div className="space-y-5">
{/* Metrics */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
label={t('proxy.advanced.metrics.requestsPerMinute')}
value={metrics ? Math.round(metrics.requestsPerMinute) : 0}
icon={Zap}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.avgLatency')}
value={metrics ? Math.round(metrics.avgLatency) : 0}
icon={Clock}
isLoading={metricsLoading}
accent="blue"
/>
<StatCard
label={t('proxy.advanced.metrics.errorRate')}
value={metrics ? Math.round(metrics.errorRate * 100) : 0}
icon={AlertTriangle}
isLoading={metricsLoading}
accent="amber"
/>
<StatCard
label={t('proxy.advanced.metrics.activeConnections')}
value={metrics?.activeConnections ?? 0}
icon={Network}
isLoading={metricsLoading}
accent="green"
/>
<StatCard
label={t('proxy.advanced.metrics.cacheStatus')}
value={metrics?.cacheHitRate ? `${Math.round(metrics.cacheHitRate * 100)}%` : 'N/A'}
icon={HardDrive}
isLoading={metricsLoading}
accent="purple"
/>
</div>
{/* Circuit Breakers */}
<div className="bg-card overflow-hidden rounded-xl border border-white/[0.06]">
<div className="border-b border-white/[0.06] px-5 py-4">
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.circuitBreaker.title')}</h4>
</div>
<div className="p-5">
{cbLoading ? (
<div className="flex h-20 items-center justify-center">
<Loader2 className="h-5 w-5 animate-spin" />
</div>
) : !circuitBreakers ? (
<p className="text-muted-foreground text-[13px]">No data</p>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{Object.entries(circuitBreakers.states).map(([api, state]) => (
<div
key={api}
className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4"
>
<div className="flex items-center justify-between">
<span className="text-[13px] font-medium">{api}</span>
<Badge variant={getCircuitBadgeVariant(state.state)}>{state.state}</Badge>
</div>
<div className="text-muted-foreground mt-2 text-xs">
{t('proxy.advanced.circuitBreaker.failures')}: {state.failures}
</div>
{state.lastFailure && (
<div className="text-muted-foreground text-xs">
{t('proxy.advanced.circuitBreaker.lastFailure')}:{' '}
{new Date(state.lastFailure).toLocaleString()}
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
{/* Traffic Logs */}
<div className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h3 className="text-[13px] font-semibold">{t('proxy.advanced.trafficLog.title')}</h3>
{stats && <span className="text-muted-foreground text-xs">Total: {stats.total}</span>}
</div>
<div className="flex items-center gap-2">
<Select value={direction} onValueChange={setDirection}>
<SelectTrigger className="h-8 w-[140px] text-xs">
<SelectValue placeholder={t('proxy.advanced.trafficLog.direction')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('proxy.advanced.trafficLog.all')}</SelectItem>
<SelectItem value="inbound">{t('proxy.advanced.trafficLog.inbound')}</SelectItem>
<SelectItem value="outbound">{t('proxy.advanced.trafficLog.outbound')}</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('json')}
>
{t('proxy.advanced.trafficLog.exportJson')}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => handleExport('csv')}
>
{t('proxy.advanced.trafficLog.exportCsv')}
</Button>
</div>
</div>
<TrafficLogTable logs={logs || []} isLoading={logsLoading} />
</div>
</div>
);
};
+17 -17
View File
@@ -1,11 +1,18 @@
/** Premium chart palette — refined for dark mode contrast */
export const COLOR_PROMPT = '#60a5fa'; // blue-400
export const COLOR_PROMPT = '#60a5fa'; // blue-400
export const COLOR_COMPLETION = '#34d399'; // emerald-400
export const COLOR_TOTAL = '#fbbf24'; // amber-400
export const COLOR_TOTAL = '#fbbf24'; // amber-400
export const PIE_COLORS = [
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
'#60a5fa',
'#34d399',
'#fbbf24',
'#a78bfa',
'#f472b6',
'#38bdf8',
'#4ade80',
'#facc15',
'#c084fc',
];
export const MODEL_COLORS: Record<string, string> = {
@@ -31,10 +38,7 @@ export function BarTooltip({ indexValue, data }: any) {
<div className="flex flex-col gap-1">
{keys.map((key: string) => (
<div key={key} className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: colorMap[key] }}
/>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: colorMap[key] }} />
<span className="text-muted-foreground">{key}:</span>
<span className="text-foreground font-mono font-medium">
{typeof data[key] === 'number' ? data[key].toLocaleString() : data[key]}
@@ -50,10 +54,7 @@ export function PieTooltip({ datum }: any) {
return (
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
<div className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: datum.color }}
/>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: datum.color }} />
<span className="text-muted-foreground">{datum.id}:</span>
<span className="text-foreground font-mono font-medium">
{typeof datum.value === 'number' ? datum.value.toLocaleString() : datum.value}
@@ -66,12 +67,11 @@ export function PieTooltip({ datum }: any) {
export function LineTooltip({ point }: any) {
return (
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
<p className="text-foreground mb-1 text-xs font-medium">
{point.data.xFormatted ?? point.data.x}
</p>
<div className="flex items-center gap-2 text-xs">
<span
className="h-2 w-2 rounded-full"
style={{ backgroundColor: point.serieColor }}
/>
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: point.serieColor }} />
<span className="text-muted-foreground">{point.serieId}:</span>
<span className="text-foreground font-mono font-medium">
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
+4 -11
View File
@@ -11,7 +11,7 @@ interface TrendInfo {
}
interface StatCardProps {
label: string;
label: React.ReactNode;
value: number | string;
icon: LucideIcon;
isLoading?: boolean;
@@ -82,10 +82,7 @@ function Sparkline({ data, accent }: { data: number[]; accent: string }) {
</linearGradient>
</defs>
{/* Area fill */}
<polygon
points={`0,${h} ${points.join(' ')} ${w},${h}`}
fill={`url(#sparkGrad-${accent})`}
/>
<polygon points={`0,${h} ${points.join(' ')} ${w},${h}`} fill={`url(#sparkGrad-${accent})`} />
{/* Line */}
<polyline
points={points.join(' ')}
@@ -103,11 +100,7 @@ function TrendBadge({ trend }: { trend: TrendInfo }) {
const isUp = trend.direction === 'up';
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
const color = isNeutral
? 'text-muted-foreground'
: isUp
? 'text-emerald-400'
: 'text-rose-400';
const color = isNeutral ? 'text-muted-foreground' : isUp ? 'text-emerald-400' : 'text-rose-400';
return (
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
@@ -130,7 +123,7 @@ export const StatCard: React.FC<StatCardProps> = ({
return (
<div
className={cn(
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
'group bg-card relative rounded-xl border border-white/[0.06] p-4 transition-all duration-200',
'hover:border-white/[0.1] hover:bg-white/[0.02]',
className,
)}
+406
View File
@@ -0,0 +1,406 @@
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="text-muted-foreground h-3.5 w-3.5" />
) : (
<ChevronRight className="text-muted-foreground/50 h-3.5 w-3.5" />
)}
</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="text-muted-foreground mb-3 rounded-lg bg-white/[0.03] px-3 py-2 text-xs break-words italic">
"{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-[10px] text-blue-400"
>
Input: {log.tokensPrompt.toLocaleString()} tokens
</Badge>
)}
{log.tokensCompletion != null && (
<Badge
variant="outline"
className="border-emerald-500/20 text-[10px] text-emerald-400"
>
Output: {log.tokensCompletion.toLocaleString()} tokens
</Badge>
)}
{log.tokensTotal != null && log.model && (
<Badge
variant="outline"
className="border-amber-500/20 text-[10px] text-amber-400"
>
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 tracking-wider uppercase">
{log.direction === 'inbound'
? 'Request Body'
: log.direction === 'outbound'
? 'Response Body'
: 'Payload'}
</span>
</div>
<pre className="custom-scrollbar text-foreground/80 max-h-[400px] overflow-auto rounded-lg bg-black/30 p-3 font-mono text-[11px] leading-relaxed break-all whitespace-pre-wrap">
{formatBody(log.body)}
</pre>
</div>
</div>
</td>
</tr>
)}
</React.Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
};
+36
View File
@@ -0,0 +1,36 @@
// Costs in USD per 1M tokens
export const MODEL_PRICING: Record<string, { input: number; output: number }> = {
'gemini-3.1-pro': { input: 1.25, output: 5.0 },
'gemini-3.1-pro-preview': { input: 1.25, output: 5.0 },
'gemini-3-flash': { input: 0.075, output: 0.3 },
'claude-3-5-sonnet': { input: 3.0, output: 15.0 },
'claude-sonnet-4-6': { input: 3.0, output: 15.0 },
// fallback for unknowns
default: { input: 1.0, output: 4.0 },
};
export function calculateCost(
model: string,
inputTokens: number,
outputTokens: number,
customPricing?: Record<string, { input: number; output: number }>,
): number {
// Check custom pricing first
if (customPricing && customPricing[model]) {
const cp = customPricing[model];
return (inputTokens / 1_000_000) * cp.input + (outputTokens / 1_000_000) * cp.output;
}
// Normalize model name roughly for default matching
const normalizedModel = model.toLowerCase().replace('models/', '');
let pricing = MODEL_PRICING['default'];
for (const [key, value] of Object.entries(MODEL_PRICING)) {
if (normalizedModel.includes(key)) {
pricing = value;
break;
}
}
return (inputTokens / 1_000_000) * pricing.input + (outputTokens / 1_000_000) * pricing.output;
}
+1 -1
View File
@@ -685,7 +685,7 @@ export async function setAutoSwitchEnabled(enabled: boolean): Promise<void> {
// Trigger an immediate check if enabled
if (enabled) {
const { CloudMonitorService } = await import('../../services/CloudMonitorService');
CloudMonitorService.poll().catch((err: unknown) =>
CloudMonitorService.poll().catch((err: unknown) =>
logger.error('Failed to poll after enabling auto-switch', err),
);
}
+25 -8
View File
@@ -28,6 +28,7 @@ import { CloudAccountRepo } from '../database/cloudHandler';
import { CloudAccountSchema } from '../../types/cloudAccount';
import { DeviceProfileSchema, DeviceProfilesSnapshotSchema } from '../../types/account';
import { logger } from '../../utils/logger';
import { redactCloudAccountTokens } from '../../utils/cloud-account-redaction';
import { getSwitchMetricsSnapshot } from '../switchMetrics';
import { getSwitchGuardSnapshot } from '../switchGuard';
import { getDeviceHardeningSnapshot } from '../device/handler';
@@ -107,11 +108,11 @@ export const cloudRouter = os.router({
.input(z.object({ authCode: z.string(), oauthClientKey: z.string().optional() }))
.output(CloudAccountSchema)
.handler(async ({ input }) => {
return addGoogleAccount(input.authCode, input.oauthClientKey);
return redactCloudAccountTokens(await addGoogleAccount(input.authCode, input.oauthClientKey));
}),
listCloudAccounts: os.output(z.array(CloudAccountSchema)).handler(async () => {
return listCloudAccounts();
return (await listCloudAccounts()).map(redactCloudAccountTokens);
}),
deleteCloudAccount: os
@@ -125,7 +126,7 @@ export const cloudRouter = os.router({
.input(z.object({ accountId: z.string() }))
.output(CloudAccountSchema)
.handler(async ({ input }) => {
return refreshAccountQuota(input.accountId);
return redactCloudAccountTokens(await refreshAccountQuota(input.accountId));
}),
switchCloudAccount: os
@@ -193,7 +194,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;
}
}),
@@ -202,9 +207,13 @@ export const cloudRouter = os.router({
try {
const result = await CloudAccountRepo.syncFromIDE();
return result;
return result ? redactCloudAccountTokens(result) : 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 +283,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 +311,11 @@ export const cloudRouter = os.router({
try {
return await importCloudAccounts(input.jsonContent, input.strategy);
} catch (error: unknown) {
logger.error('[ORPC] importCloudAccounts error:', extractErrorMessage(error), error instanceof Error ? error.stack : undefined);
logger.error(
'[ORPC] importCloudAccounts error:',
extractErrorMessage(error),
error instanceof Error ? error.stack : undefined,
);
throw error;
}
}),
+51 -21
View File
@@ -89,12 +89,8 @@ export function ensureProxyMetricsTable(rawDb: Database.Database): void {
metadata_json TEXT
);
`);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`,
);
rawDb.exec(
`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`,
);
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_timestamp ON traffic_logs(timestamp);`);
rawDb.exec(`CREATE INDEX IF NOT EXISTS idx_traffic_logs_direction ON traffic_logs(direction);`);
} catch (error) {
logger.error('Failed to ensure proxy_request_metrics table', error);
throw error;
@@ -201,17 +197,30 @@ export class ProxyMetricsRepo {
const reqRow = raw
.prepare(
`SELECT COUNT(*) as total, AVG(duration_ms) as avgLatency,
MIN(timestamp) as minTimestamp, MAX(timestamp) as maxTimestamp,
CASE WHEN COUNT(*) = 0 THEN 0
ELSE (COUNT(CASE WHEN status_code >= 400 THEN 1 END) * 100.0 / COUNT(*))
END as errorRate
FROM proxy_request_metrics WHERE timestamp >= ?`,
)
.get(cutoff) as
| { total: number; avgLatency: number | null; errorRate: number }
| {
total: number;
avgLatency: number | null;
errorRate: number;
minTimestamp: number | null;
maxTimestamp: number | null;
}
| undefined;
const totalRequests = reqRow?.total ?? 0;
const minutes = Math.max(windowMs / 60_000, 1);
let minutes = Math.max(windowMs / 60_000, 1);
if (reqRow?.minTimestamp && reqRow?.maxTimestamp && totalRequests > 0) {
const spanMs = reqRow.maxTimestamp - reqRow.minTimestamp;
minutes = Math.max(spanMs / 60_000, 1);
}
const requestsPerMinute = Math.round((totalRequests / minutes) * 100) / 100;
const avgLatencyMs = reqRow?.avgLatency ? Math.round(reqRow.avgLatency) : 0;
const errorRatePercent = reqRow?.errorRate ?? 0;
@@ -244,7 +253,9 @@ export class ProxyMetricsRepo {
const { raw } = getProxyMetricsDb();
try {
raw.prepare(`DELETE FROM proxy_request_metrics WHERE timestamp < ?`).run(cutoffTimestamp);
raw.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`).run(cutoffTimestamp);
raw
.prepare(`DELETE FROM proxy_connection_snapshots WHERE timestamp < ?`)
.run(cutoffTimestamp);
return true;
} catch (error) {
logger.error('Failed to delete old proxy metrics records', error);
@@ -298,7 +309,10 @@ export class TrafficLogsRepo {
}
}
static getRecent(limit: number = 50, direction?: string): Array<{
static getRecent(
limit: number = 50,
direction?: string,
): Array<{
timestamp: number;
direction: string;
requestId: string;
@@ -349,10 +363,13 @@ export class TrafficLogsRepo {
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
error: r.error_message
? {
message: r.error_message,
stack: r.error_stack ?? undefined,
status: r.error_status ?? undefined,
}
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
@@ -361,10 +378,18 @@ export class TrafficLogsRepo {
}
}
static getStats(): { byDirection: Record<string, number>; byEndpoint: Record<string, number>; total: number } {
static getStats(): {
byDirection: Record<string, number>;
byEndpoint: Record<string, number>;
total: number;
} {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`).all() as Array<{
const rows = raw
.prepare(
`SELECT direction, endpoint, COUNT(*) as cnt FROM traffic_logs GROUP BY direction, endpoint`,
)
.all() as Array<{
direction: string;
endpoint: string;
cnt: number;
@@ -400,7 +425,9 @@ export class TrafficLogsRepo {
}> {
const { raw } = getProxyMetricsDb();
try {
const rows = raw.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`).all() as Array<{
const rows = raw
.prepare(`SELECT * FROM traffic_logs ORDER BY timestamp DESC`)
.all() as Array<{
timestamp: number;
direction: string;
request_id: string;
@@ -427,10 +454,13 @@ export class TrafficLogsRepo {
body: r.body_json ? JSON.parse(r.body_json) : undefined,
chunk: r.chunk_json ? JSON.parse(r.chunk_json) : undefined,
durationMs: r.duration_ms ?? undefined,
error:
r.error_message
? { message: r.error_message, stack: r.error_stack ?? undefined, status: r.error_status ?? undefined }
: undefined,
error: r.error_message
? {
message: r.error_message,
stack: r.error_stack ?? undefined,
status: r.error_status ?? undefined,
}
: undefined,
metadata: r.metadata_json ? JSON.parse(r.metadata_json) : undefined,
}));
} catch (error) {
+1 -7
View File
@@ -1,9 +1,5 @@
import { logger } from '../../utils/logger';
import {
bootstrapNestServer,
isNestServerRunning,
stopNestServer,
} from '../../server/main';
import { bootstrapNestServer, isNestServerRunning, stopNestServer } from '../../server/main';
import { ConfigManager } from '../config/manager';
/**
@@ -25,8 +21,6 @@ export async function closeGeminiNexus(): Promise<void> {
await stopNestServer();
}
/**
* Waits for the Gemini Nexus process to exit.
* Polls the NestJS server status until it stops or the timeout is reached.
+203 -31
View File
@@ -1,8 +1,10 @@
import { logger } from '../../utils/logger';
import { getServiceOrThrow } from './service-registry';
import { getServiceOrThrow, getServiceOptional } from './service-registry';
import type { IdeConfigResult } from '../../server/modules/proxy/proxy-ide-config.service';
import { 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 +86,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 +118,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 +150,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 +190,7 @@ export async function getCircuitBreakerStatus(): Promise<{
export async function getProxyMetrics(): Promise<{
success: boolean;
data?: {
data?: {
uptimeSeconds: number;
totalRequests: number;
activeConnections: number;
@@ -166,7 +202,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 +237,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),
@@ -199,9 +254,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 }> {
export async function replayRequest(requestId: string): Promise<{
success: boolean;
data?: { original: unknown; newResponse: unknown };
error?: string;
}> {
try {
const proxyReplay = getServiceOrThrow('proxyReplay');
const result = await proxyReplay.replayRequest(requestId);
@@ -278,14 +335,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 +352,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 +362,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 +372,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 +382,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 +392,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 +412,126 @@ 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';
try {
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;
}
}
}
}
} catch (error) {
// Local proxy configuration remains usable when LAN enumeration is restricted.
logger.warn('Could not enumerate network interfaces; using loopback', error);
}
// Fallback generator mimicking the proxyIdeConfig.service
const baseUrl = `http://${localIp}:${port}/v1`;
let result: IdeConfigResult;
switch (normalizedIde) {
case 'cursor':
result = {
ide: normalizedIde,
format: 'json',
content: { OPENAI_API_KEY: apiKey, OPENAI_BASE_URL: baseUrl },
};
break;
case 'vscode':
result = {
ide: normalizedIde,
format: 'json',
content: { 'openai.url': baseUrl, 'openai.apiKey': apiKey },
};
break;
case 'claude-code':
result = {
ide: normalizedIde,
format: 'shell',
content: `export OPENAI_API_KEY=${apiKey} && export OPENAI_BASE_URL=${baseUrl}`,
};
break;
case 'jetbrains':
result = {
ide: normalizedIde,
format: 'instructions',
content: [
'JetBrains AI Assistant Setup:',
'1. Open Settings (File > Settings or Ctrl+Alt+S).',
'2. Navigate to Languages & Frameworks > AI Assistant.',
'3. Under "Custom OpenAI Endpoint", set:',
` - Base URL: ${baseUrl}`,
` - API Key: ${apiKey}`,
'4. Click "Apply" and restart the IDE if prompted.',
].join('\n'),
};
break;
case 'opencide':
result = {
ide: normalizedIde,
format: 'env',
content: `OPENAI_API_KEY=${apiKey}\nOPENAI_BASE_URL=${baseUrl}`,
};
break;
case 'opencode':
result = {
ide: normalizedIde,
format: 'json',
content: {
apiType: 'openai',
apiKey: apiKey,
baseURL: baseUrl,
models: { chat: [defaultModel || 'gemini-3.1-pro-high'] },
},
};
break;
default:
throw new Error('Unsupported');
}
return {
+7 -5
View File
@@ -155,9 +155,11 @@ const IdeConfigResponseSchema = z.object({
.object({
name: z.string(),
settingsPath: z.string(),
proxySetting: z.string(),
apiKeySetting: z.string(),
instructions: z.string(),
proxySetting: z.string().optional(),
apiKeySetting: z.string().optional(),
instructions: z.string().optional(),
format: z.enum(['json', 'shell', 'env', 'instructions']).optional(),
content: z.union([z.record(z.string(), z.unknown()), z.string()]).optional(),
})
.optional(),
error: z.string().optional(),
@@ -204,10 +206,10 @@ export const proxyAdvancedRouter = os.router({
}),
generateIdeConfig: os
.input(z.object({ ide: z.string() }))
.input(z.object({ ide: z.string(), defaultModel: z.string().optional() }))
.output(IdeConfigResponseSchema)
.handler(async ({ input }) => {
return generateIdeConfig(input.ide);
return generateIdeConfig(input.ide, input.defaultModel);
}),
replayRequest: os
@@ -37,3 +37,9 @@ export function getServiceOrThrow<K extends keyof ProxyAdvancedRegistry>(
if (!svc) throw new Error(`ProxyAdvanced service '${key}' is not registered`);
return svc;
}
export function getServiceOptional<K extends keyof ProxyAdvancedRegistry>(
key: K,
): ProxyAdvancedRegistry[K] {
return proxyAdvancedRegistry[key];
}
Loaded 100 of 394 files, more files were not shown because too many files have changed in this diff. Show more