bardd Cursor commited on
Commit
4badc3b
·
0 Parent(s):

Fix gemini-3.1-pro-high by routing to pro-low with thinkingLevel high.

Browse files

The upstream API rejects gemini-3.1-pro-high as a direct model id. Map pro-high
requests to gemini-3.1-pro-low with high thinking, skip duplicate thinking on
tier SKUs, and add legacy Antigravity client headers.

Co-authored-by: Cursor <cursoragent@cursor.com>

This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +12 -0
  2. .gitattributes +2 -0
  3. .github/FUNDING.yml +1 -0
  4. .github/ISSUE_TEMPLATE/bug_report.md +53 -0
  5. .github/ISSUE_TEMPLATE/config.yml +14 -0
  6. .github/ISSUE_TEMPLATE/feature_request.md +28 -0
  7. .github/workflows/publish.yml +29 -0
  8. .gitignore +36 -0
  9. .npmignore +21 -0
  10. CLAUDE.md +51 -0
  11. Dockerfile +42 -0
  12. LICENSE +21 -0
  13. README.md +52 -0
  14. bin/cli.js +490 -0
  15. config.example.json +60 -0
  16. docs/api-endpoints.md +9 -0
  17. docs/configuration.md +94 -0
  18. docs/development.md +54 -0
  19. docs/legal.md +9 -0
  20. docs/load-balancing.md +59 -0
  21. docs/menubar-app.md +17 -0
  22. docs/models.md +19 -0
  23. docs/openclaw.md +159 -0
  24. docs/opencode.md +150 -0
  25. docs/safety-notices.md +27 -0
  26. docs/testing.md +24 -0
  27. docs/troubleshooting.md +145 -0
  28. docs/web-console.md +20 -0
  29. package-lock.json +0 -0
  30. package.json +78 -0
  31. postcss.config.js +6 -0
  32. public/app.js +227 -0
  33. public/css/src/input.css +523 -0
  34. public/css/style.css +0 -0
  35. public/favicon.svg +10 -0
  36. public/index.html +434 -0
  37. public/js/components/account-manager.js +502 -0
  38. public/js/components/add-account-modal.js +88 -0
  39. public/js/components/claude-config.js +508 -0
  40. public/js/components/dashboard.js +323 -0
  41. public/js/components/dashboard/charts.js +590 -0
  42. public/js/components/dashboard/filters.js +362 -0
  43. public/js/components/dashboard/stats.js +110 -0
  44. public/js/components/logs-viewer.js +131 -0
  45. public/js/components/model-dropdown.js +151 -0
  46. public/js/components/models.js +301 -0
  47. public/js/components/server-config.js +1203 -0
  48. public/js/config/constants.js +165 -0
  49. public/js/data-store.js +538 -0
  50. public/js/settings-store.js +76 -0
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ node_modules
4
+ npm-debug.log*
5
+ .env
6
+ .env.*
7
+ data
8
+ *.log
9
+ tests
10
+ coverage
11
+ README.hf.md
12
+ IMPLEMENTATION_GUIDE.md
.gitattributes ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # Mark generated CSS file to reduce diff noise
2
+ public/css/style.css linguist-generated=true -diff
.github/FUNDING.yml ADDED
@@ -0,0 +1 @@
 
 
1
+ buy_me_a_coffee: badrinarayanans
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug Report
3
+ about: Report a bug or issue with the proxy
4
+ title: '[BUG] '
5
+ labels: bug
6
+ assignees: ''
7
+ ---
8
+
9
+ **Bug Description**
10
+ A clear and concise description of the bug.
11
+
12
+ **Steps to Reproduce**
13
+ 1.
14
+ 2.
15
+ 3.
16
+
17
+ **Expected Behavior**
18
+ What should happen.
19
+
20
+ **Actual Behavior**
21
+ What actually happens.
22
+
23
+ **Environment**
24
+ - Claude Code version:
25
+ - antigravity-claude-proxy version:
26
+ - Operating System:
27
+ - Node.js version:
28
+
29
+ **Claude CLI Configuration**
30
+ Please provide ONE of the following:
31
+ - Screenshot of WebUI **Settings → Claude CLI** tab, OR
32
+ - Contents of your Claude settings.json file:
33
+ - macOS/Linux: `~/.claude/settings.json`
34
+ - Windows: `%USERPROFILE%\.claude\settings.json`
35
+
36
+ ```json
37
+ <!-- Paste your settings.json here -->
38
+ ```
39
+
40
+ **Logs (if applicable)**
41
+
42
+
43
+ **Checklist**
44
+ Please confirm:
45
+ - [ ] I've updated antigravity-claude-proxy to the latest version
46
+ - [ ] The bug exists in the latest version
47
+ - [ ] I'm using this proxy for personal development only
48
+ - [ ] I have an active Google Cloud project with Antigravity enabled
49
+ - [ ] This issue is not related to attempting commercial use or TOS violations
50
+ - [ ] I've reviewed the README troubleshooting section
51
+
52
+ **Additional Context**
53
+ Add any other relevant information.
.github/ISSUE_TEMPLATE/config.yml ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ blank_issues_enabled: false
2
+ contact_links:
3
+ - name: Troubleshooting Guide
4
+ url: https://github.com/badrisnarayanan/antigravity-claude-proxy/blob/main/docs/troubleshooting.md
5
+ about: Check here first for common issues and solutions before creating a bug report
6
+ - name: Google Cloud Support
7
+ url: https://cloud.google.com/support
8
+ about: For Google Cloud account or billing issues, contact Google Cloud directly
9
+ - name: Google Terms of Service
10
+ url: https://policies.google.com/terms
11
+ about: Review Google's Terms of Service for compliance questions
12
+ - name: Discussions
13
+ url: https://github.com/badrisnarayanan/antigravity-claude-proxy/discussions
14
+ about: Ask questions or discuss the proxy with the community
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature Request
3
+ about: Suggest a new feature or enhancement
4
+ title: '[FEATURE] '
5
+ labels: enhancement
6
+ assignees: ''
7
+ ---
8
+
9
+ **Feature Description**
10
+ A clear description of the feature you'd like to see.
11
+
12
+ **Use Case**
13
+ Explain how this feature would be used and what problem it solves.
14
+
15
+ **Proposed Implementation**
16
+ If you have ideas about how this could be implemented, share them here.
17
+
18
+ **Compliance Consideration**
19
+ Please confirm:
20
+ - [ ] This feature is for personal development use
21
+ - [ ] This feature does not violate or circumvent Google's Terms of Service
22
+ - [ ] This feature is not intended for commercial resale or multi-user access
23
+
24
+ **Alternatives Considered**
25
+ Have you considered any alternative solutions or workarounds?
26
+
27
+ **Additional Context**
28
+ Add any other context, screenshots, or examples.
.github/workflows/publish.yml ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Publish to npm
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ publish:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - name: Checkout
12
+ uses: actions/checkout@v4
13
+
14
+ - name: Setup Node.js
15
+ uses: actions/setup-node@v4
16
+ with:
17
+ node-version: '22'
18
+ registry-url: 'https://registry.npmjs.org'
19
+
20
+ - name: Install dependencies
21
+ run: npm ci
22
+
23
+ - name: Update version from release tag
24
+ run: npm version ${{ github.event.release.tag_name }} --no-git-tag-version --allow-same-version
25
+
26
+ - name: Publish to npm
27
+ run: npm publish
28
+ env:
29
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
.gitignore ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies
2
+ node_modules/
3
+
4
+ # OS files
5
+ .DS_Store
6
+ Thumbs.db
7
+
8
+ # Logs
9
+ *.log
10
+ log.txt
11
+
12
+ # IDE
13
+ .vscode/
14
+ .idea/
15
+ .history/
16
+
17
+ # Local config (may contain tokens)
18
+ .claude/
19
+ .deepvcode/
20
+
21
+ # Runtime data
22
+ data/
23
+
24
+ # Test artifacts
25
+ tests/utils/*.png
26
+ tests/utils/*.jpg
27
+ tests/utils/*.jpeg
28
+
29
+ # Environment
30
+ .env
31
+ .env.local
32
+ hf-config-env.txt
33
+
34
+ # Build output
35
+ dist/
36
+ build/
.npmignore ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Exclude tests
2
+ tests/
3
+
4
+ # Exclude GitHub workflows
5
+ .github/
6
+
7
+ # Exclude documentation (except README)
8
+ CLAUDE.md
9
+ CHANGELOG.md
10
+ CONTRIBUTING.md
11
+ docs/
12
+
13
+ # Exclude dev files
14
+ .env*
15
+ .claude/
16
+ .vscode/
17
+ .idea/
18
+
19
+ # Exclude misc
20
+ *.log
21
+ .DS_Store
CLAUDE.md ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ Node.js proxy that exposes an Anthropic-compatible API backed by Google's Cloud Code service, letting Claude Code CLI use Gemini (and Claude) models via Google accounts with multi-account quota management.
4
+
5
+ Request flow: `Claude Code CLI → Express (server.js) → CloudCode client → Antigravity Cloud Code API`
6
+
7
+ ## Commands
8
+
9
+ ```bash
10
+ npm install # installs deps and builds CSS (prepare hook)
11
+ npm start # port 8080
12
+ npm run dev # watch server files
13
+ npm run dev:full # watch CSS + server files
14
+
15
+ npm start -- --strategy=sticky # cache-optimized (default is hybrid)
16
+ npm start -- --strategy=round-robin # load-balanced
17
+ npm start -- --fallback # fall back to alternate model on quota exhaustion
18
+ npm start -- --dev-mode # enables debug logging + dev tools (--debug is a legacy alias)
19
+
20
+ npm run build:css # compile Tailwind once
21
+ npm run watch:css # watch CSS
22
+
23
+ npm run accounts:add # add Google account via OAuth
24
+ npm run accounts:add -- --no-browser # headless/manual code input
25
+ npm run accounts:list
26
+ npm run accounts:verify
27
+
28
+ npm test # requires server running on port 8080
29
+ node tests/run-all.cjs <filter> # run matching tests only
30
+ node tests/test-strategies.cjs # strategy unit tests (no server needed)
31
+ ```
32
+
33
+ ## Non-obvious things
34
+
35
+ **CSS**: Source is `public/css/src/input.css` (Tailwind + `@apply`). Compiled output is `public/css/style.css` — don't edit the compiled file.
36
+
37
+ **Quota thresholds** are stored as fractions (0–0.99) but displayed as percentages in the UI. Three-tier resolution: per-model > per-account > global.
38
+
39
+ **`cache_control` stripping**: Claude Code CLI sends `cache_control` on content blocks; Cloud Code API rejects them. Stripped at the start of `convertAnthropicToGoogle()` before any other processing.
40
+
41
+ **Cross-model thinking signatures**: Claude and Gemini signatures are incompatible. When switching models mid-conversation, mismatched signatures are dropped. Gemini targets: strict (drop unknown). Claude targets: lenient (let Claude validate).
42
+
43
+ **`CLAUDE_CONFIG_PATH` env var**: Set this when running as a systemd service — `os.homedir()` returns the service user's home, not the real user's.
44
+
45
+ **`WEBUI_PASSWORD` env var**: Enables password protection on the web UI.
46
+
47
+ **Native module rebuild**: On Node.js version mismatch, `better-sqlite3` is auto-rebuilt via `npm rebuild`. If reload still fails after rebuild, a server restart is required.
48
+
49
+ **Dev mode sub-toggles** are client-side only (localStorage in `settings-store.js`): screenshot/redact mode, debug logging, log export, health inspector, placeholder data. No backend involvement.
50
+
51
+ **`/api/strategy/health`** returns 403 unless dev mode is on.
Dockerfile ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm-slim AS build
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update \
6
+ && apt-get install -y --no-install-recommends python3 make g++ \
7
+ && rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY . .
10
+ RUN npm ci
11
+ RUN npm run build:css
12
+ RUN npm prune --omit=dev
13
+
14
+ FROM node:20-bookworm-slim AS runtime
15
+
16
+ WORKDIR /app
17
+
18
+ ENV NODE_ENV=production \
19
+ PORT=7860 \
20
+ HOST=0.0.0.0 \
21
+ HOME=/home/node \
22
+ ACCOUNT_CONFIG_PATH=/data/accounts.json
23
+
24
+ RUN apt-get update \
25
+ && apt-get install -y --no-install-recommends ca-certificates tini \
26
+ && rm -rf /var/lib/apt/lists/* \
27
+ && mkdir -p /data /home/node/.config/antigravity-proxy \
28
+ && chown -R node:node /data /home/node /app
29
+
30
+ COPY --from=build --chown=node:node /app/package.json ./package.json
31
+ COPY --from=build --chown=node:node /app/node_modules ./node_modules
32
+ COPY --from=build --chown=node:node /app/src ./src
33
+ COPY --from=build --chown=node:node /app/bin ./bin
34
+ COPY --from=build --chown=node:node /app/public ./public
35
+ COPY --from=build --chown=node:node /app/config.example.json ./config.example.json
36
+
37
+ USER node
38
+
39
+ EXPOSE 7860
40
+
41
+ ENTRYPOINT ["tini", "--"]
42
+ CMD ["node", "src/index.js"]
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2024 Badri Narayanan S
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Antigravity OpenAI Proxy
3
+ emoji: 🛸
4
+ colorFrom: indigo
5
+ colorTo: blue
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Antigravity OpenAI Proxy
12
+
13
+ A test-only Hugging Face Space that exposes both:
14
+
15
+ - `POST /v1/chat/completions` (OpenAI-compatible)
16
+ - `POST /v1/messages` (Anthropic-compatible)
17
+
18
+ The proxy rotates across Antigravity OAuth accounts using the existing hybrid account manager.
19
+
20
+ ## Required Space secrets
21
+
22
+ - `PROXY_API_KEY`: bearer token required by `/v1/*`
23
+ - `CONFIG_ENV`: newline-delimited placeholder-compatible account variables such as `ANTIGRAVITY_1_EMAIL` and `ANTIGRAVITY_1_REFRESH_TOKEN`
24
+
25
+ Optional variables:
26
+
27
+ - `FORCE_ENV_ACCOUNTS=true`: make environment accounts authoritative at each cold start
28
+ - `ENABLE_WEBUI=false`: disable the browser UI
29
+ - `ACCOUNT_STRATEGY=hybrid`: account selection strategy
30
+
31
+ Never commit OAuth refresh tokens or a real `.env` file.
32
+
33
+ ## Health check
34
+
35
+ ```bash
36
+ curl -fsS https://YOUR-SPACE.hf.space/health
37
+ ```
38
+
39
+ ## OpenAI-compatible request
40
+
41
+ ```bash
42
+ curl -sS https://YOUR-SPACE.hf.space/v1/chat/completions \
43
+ -H "Authorization: Bearer YOUR_PROXY_API_KEY" \
44
+ -H "Content-Type: application/json" \
45
+ -d '{
46
+ "model": "antigravity/gemini-3-flash",
47
+ "messages": [{"role": "user", "content": "Reply with READY only."}],
48
+ "reasoning_effort": "high"
49
+ }'
50
+ ```
51
+
52
+ This Space is for testing only. Antigravity access may be subject to Google terms, quotas, and account enforcement.
bin/cli.js ADDED
@@ -0,0 +1,490 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env node
2
+
3
+ import { fileURLToPath } from 'url';
4
+ import { dirname, join } from 'path';
5
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
6
+ import { spawn, exec } from 'child_process';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ // Read package.json for version
12
+ const packageJson = JSON.parse(
13
+ readFileSync(join(__dirname, '..', 'package.json'), 'utf-8')
14
+ );
15
+
16
+ // PID file location for background process management
17
+ const CONFIG_DIR = join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'antigravity-proxy');
18
+ const PID_FILE = join(CONFIG_DIR, 'server.pid');
19
+
20
+ const args = process.argv.slice(2);
21
+ const command = args[0];
22
+
23
+ // Ensure config directory exists
24
+ function ensureConfigDir() {
25
+ if (!existsSync(CONFIG_DIR)) {
26
+ mkdirSync(CONFIG_DIR, { recursive: true });
27
+ }
28
+ }
29
+
30
+ /**
31
+ * Check if the service is running by reading PID file and verifying process
32
+ */
33
+ function isServiceRunning() {
34
+ if (!existsSync(PID_FILE)) {
35
+ return false;
36
+ }
37
+
38
+ try {
39
+ const pidStr = readFileSync(PID_FILE, 'utf-8');
40
+ const pid = parseInt(pidStr, 10);
41
+ if (isNaN(pid)) {
42
+ cleanupPidFile();
43
+ return false;
44
+ }
45
+
46
+ // Check if process is running (signal 0 doesn't kill, just checks)
47
+ process.kill(pid, 0);
48
+ return true;
49
+ } catch (e) {
50
+ // Process doesn't exist
51
+ cleanupPidFile();
52
+ return false;
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Get the PID of the running service
58
+ */
59
+ function getServicePid() {
60
+ if (!existsSync(PID_FILE)) {
61
+ return null;
62
+ }
63
+ try {
64
+ const pid = parseInt(readFileSync(PID_FILE, 'utf-8'), 10);
65
+ return isNaN(pid) ? null : pid;
66
+ } catch (e) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Save PID to file
73
+ */
74
+ function savePid(pid) {
75
+ if (!existsSync(CONFIG_DIR)) {
76
+ mkdirSync(CONFIG_DIR, { recursive: true });
77
+ }
78
+ writeFileSync(PID_FILE, pid.toString());
79
+ }
80
+
81
+ /**
82
+ * Clean up PID file
83
+ */
84
+ function cleanupPidFile() {
85
+ if (existsSync(PID_FILE)) {
86
+ try {
87
+ unlinkSync(PID_FILE);
88
+ } catch (e) {
89
+ // Ignore cleanup errors
90
+ }
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Get current port from environment or default
96
+ */
97
+ function getPort() {
98
+ return process.env.PORT || 8080;
99
+ }
100
+
101
+ /**
102
+ * Wait for service to be ready
103
+ */
104
+ async function waitForService(timeout = 10000, initialDelay = 1000) {
105
+ await new Promise(resolve => setTimeout(resolve, initialDelay));
106
+
107
+ const startTime = Date.now();
108
+ while (Date.now() - startTime < timeout) {
109
+ if (isServiceRunning()) {
110
+ // Additional wait to ensure service is fully ready
111
+ await new Promise(resolve => setTimeout(resolve, 500));
112
+ return true;
113
+ }
114
+ await new Promise(resolve => setTimeout(resolve, 100));
115
+ }
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * Start the server as a background process (or foreground with --log)
121
+ */
122
+ async function startServer() {
123
+ // Check for --log flag
124
+ const logMode = args.includes('--log');
125
+
126
+ if (isServiceRunning() && !logMode) {
127
+ console.log('');
128
+ console.log('╭' + '─'.repeat(48) + '╮');
129
+ console.log('│ 🛸 Antigravity Proxy is already in orbit │');
130
+ console.log('╰' + '─'.repeat(48) + '╯');
131
+ console.log('');
132
+
133
+ const pid = getServicePid();
134
+ const port = getPort();
135
+ console.log(` ┌─ PID: ${pid}`);
136
+ console.log(` ├─ Local: http://localhost:${port}`);
137
+ console.log(` └─ Dashboard: http://localhost:${port}`);
138
+ console.log('');
139
+ return;
140
+ }
141
+
142
+ console.log('');
143
+ if (logMode) {
144
+ console.log('🌌 Launching Antigravity Proxy (foreground mode)...');
145
+ console.log(' Press Ctrl+C to stop');
146
+ console.log('');
147
+ } else {
148
+ console.log('🌌 Launching Antigravity Proxy...');
149
+ }
150
+
151
+ const serverScript = join(__dirname, '..', 'src', 'index.js');
152
+ const port = getPort();
153
+
154
+ // Filter out --log from args passed to server
155
+ const serverArgs = args.slice(1).filter(arg => arg !== '--log');
156
+
157
+ if (logMode) {
158
+ // Foreground mode - show logs directly
159
+ const serverProcess = spawn('node', [serverScript, ...serverArgs], {
160
+ stdio: 'inherit', // Show output in current terminal
161
+ env: { ...process.env, PORT: port.toString() }
162
+ });
163
+
164
+ serverProcess.on('error', (error) => {
165
+ console.error('');
166
+ console.error('⚠️ Launch failed:', error.message);
167
+ console.error('');
168
+ process.exit(1);
169
+ });
170
+
171
+ serverProcess.on('exit', (code) => {
172
+ console.log('');
173
+ console.log('🌙 Proxy has exited');
174
+ console.log('');
175
+ process.exit(code || 0);
176
+ });
177
+
178
+ // Keep process running
179
+ return;
180
+ }
181
+
182
+ // Background mode - detached process
183
+ const serverProcess = spawn('node', [serverScript, ...serverArgs], {
184
+ detached: true,
185
+ stdio: 'ignore',
186
+ env: { ...process.env, PORT: port.toString() }
187
+ });
188
+
189
+ serverProcess.on('error', (error) => {
190
+ console.error('');
191
+ console.error('⚠️ Launch failed:', error.message);
192
+ console.error('');
193
+ process.exit(1);
194
+ });
195
+
196
+ // Save PID and detach
197
+ savePid(serverProcess.pid);
198
+ serverProcess.unref();
199
+
200
+ // Wait for service to be ready
201
+ if (await waitForService()) {
202
+ console.log('╭' + '─'.repeat(48) + '╮');
203
+ console.log('│ ⚡ Proxy is now in orbit! │');
204
+ console.log('╰' + '─'.repeat(48) + '╯');
205
+
206
+ console.log('');
207
+ console.log(' ┌─ Process ID:', serverProcess.pid);
208
+ console.log(' ├─ Local:', `http://localhost:${port}`);
209
+ console.log(' └─ Dashboard:', `http://localhost:${port}/`);
210
+ console.log('');
211
+ console.log(' Next steps:');
212
+ console.log(' • acc ui → Open dashboard');
213
+ console.log(' • acc status → View proxy health');
214
+ console.log(' • acc stop → Shut down proxy');
215
+ console.log('');
216
+ } else {
217
+ console.error('');
218
+ console.error('⚠️ Proxy launched but health check timed out');
219
+ console.log(` Try: curl http://localhost:${port}/health`);
220
+ console.error('');
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Stop the running server
226
+ */
227
+ function stopServer() {
228
+ if (!isServiceRunning()) {
229
+ console.log('');
230
+ console.log('🌑 Proxy is not running');
231
+ console.log('');
232
+ cleanupPidFile();
233
+ return;
234
+ }
235
+
236
+ const pid = getServicePid();
237
+ try {
238
+ process.kill(pid, 'SIGTERM');
239
+ cleanupPidFile();
240
+ console.log('');
241
+ console.log('🌙 Proxy has been taken offline');
242
+ console.log('');
243
+ } catch (e) {
244
+ console.error('');
245
+ console.error('⚠️ Shutdown failed:', e.message);
246
+ console.error('');
247
+ cleanupPidFile();
248
+ }
249
+ }
250
+
251
+ /**
252
+ * Restart the server
253
+ */
254
+ async function restartServer() {
255
+ console.log('');
256
+ console.log('♻️ Restarting proxy...');
257
+ console.log('');
258
+
259
+ // Stop if running
260
+ if (isServiceRunning()) {
261
+ const pid = getServicePid();
262
+ try {
263
+ process.kill(pid, 'SIGTERM');
264
+ cleanupPidFile();
265
+ console.log(' └─ Existing instance stopped');
266
+ console.log('');
267
+ // Wait for process to fully terminate
268
+ await new Promise(resolve => setTimeout(resolve, 1000));
269
+ } catch (e) {
270
+ console.log(' └─ No previous instance found');
271
+ console.log('');
272
+ cleanupPidFile();
273
+ }
274
+ }
275
+
276
+ // Start fresh
277
+ await startServer();
278
+ }
279
+
280
+ /**
281
+ * Show server status
282
+ */
283
+ function showStatus() {
284
+ console.log('');
285
+ console.log('╭' + '─'.repeat(48) + '╮');
286
+ console.log('│ 🛸 Antigravity Claude Proxy │');
287
+ console.log('╰' + '─'.repeat(48) + '╯');
288
+ console.log('');
289
+
290
+ if (isServiceRunning()) {
291
+ const pid = getServicePid();
292
+ const port = getPort();
293
+ console.log(' STATUS');
294
+ console.log(' ⚡ Proxy is active');
295
+ console.log('');
296
+ console.log(' DETAILS');
297
+ console.log(` ├─ PID: ${pid}`);
298
+ console.log(` ├─ Port: ${port}`);
299
+ console.log(` ├─ API: http://localhost:${port}`);
300
+ console.log(` └─ Dashboard: http://localhost:${port}/`);
301
+ console.log('');
302
+ console.log(' AVAILABLE COMMANDS');
303
+ console.log(' • acc ui Open dashboard');
304
+ console.log(' • acc restart Relaunch proxy');
305
+ console.log(' • acc stop Take offline');
306
+ } else {
307
+ console.log(' STATUS');
308
+ console.log(' 🌑 Proxy is offline');
309
+ console.log('');
310
+ console.log(' TO LAUNCH');
311
+ console.log(' • acc start Bring proxy online');
312
+ }
313
+ console.log('');
314
+ }
315
+
316
+ /**
317
+ * Open WebUI in browser
318
+ */
319
+ async function openUI() {
320
+ // Start server if not running
321
+ if (!isServiceRunning()) {
322
+ console.log('');
323
+ console.log('🌌 Proxy offline - launching now...');
324
+ await startServer();
325
+ // Wait for it to be fully ready
326
+ await new Promise(resolve => setTimeout(resolve, 1000));
327
+ }
328
+
329
+ const port = getPort();
330
+ const uiUrl = `http://localhost:${port}/`;
331
+
332
+ console.log('');
333
+ console.log(`🖥️ Opening dashboard → ${uiUrl}`);
334
+ console.log('');
335
+
336
+ // Open URL in browser based on platform
337
+ const platform = process.platform;
338
+ let openCommand = '';
339
+
340
+ if (platform === 'win32') {
341
+ openCommand = `start ${uiUrl}`;
342
+ } else if (platform === 'darwin') {
343
+ openCommand = `open ${uiUrl}`;
344
+ } else if (platform === 'linux') {
345
+ openCommand = `xdg-open ${uiUrl}`;
346
+ } else {
347
+ console.error('⚠️ Cannot auto-open browser on this platform');
348
+ console.log(` Manual URL: ${uiUrl}`);
349
+ console.log('');
350
+ return;
351
+ }
352
+
353
+ exec(openCommand, (error) => {
354
+ if (error) {
355
+ console.error('⚠️ Browser launch failed:', error.message);
356
+ console.log(` Manual URL: ${uiUrl}`);
357
+ console.log('');
358
+ }
359
+ });
360
+ }
361
+
362
+ function showHelp() {
363
+ console.log(`
364
+ ╭${'─'.repeat(58)}╮
365
+ │ 🛸 Antigravity Claude Proxy v${packageJson.version.padEnd(27)}│
366
+ ╰${'─'.repeat(58)}╯
367
+
368
+ Route Claude Code CLI through Antigravity's multi-model API
369
+ with intelligent load balancing across Google accounts.
370
+
371
+ USAGE
372
+ antigravity-claude-proxy <command> [options]
373
+ acc <command> [options] ← shorthand
374
+
375
+ ━━��� PROXY CONTROL ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
376
+ start Launch proxy as background service
377
+ stop Shut down the proxy
378
+ restart Relaunch the proxy
379
+ status View proxy health and details
380
+ ui Open dashboard in browser
381
+
382
+ ━━━ ACCOUNT MANAGEMENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
383
+ accounts Interactive account menu
384
+ accounts add Add Google account via OAuth
385
+ accounts list Show all linked accounts
386
+ accounts remove Unlink accounts
387
+ accounts verify Check account health
388
+ accounts clear Remove all accounts
389
+
390
+ ━━━ OPTIONS ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
391
+ --help, -h Show this help
392
+ --version, -v Show version
393
+ --log Run in foreground with visible logs
394
+ --strategy=NAME Load balancing: hybrid (default),
395
+ sticky (cache-optimized), round-robin
396
+ --fallback Enable model fallback on errors
397
+
398
+ ━━━ ENVIRONMENT ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
399
+ PORT Custom port (default: 8080)
400
+
401
+ ━━━ EXAMPLES ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
402
+ acc start Launch proxy
403
+ acc start --log Launch with visible logs
404
+ acc ui Open dashboard
405
+ PORT=3000 acc start Use custom port
406
+ acc start --strategy=sticky Optimize for prompt caching
407
+ acc accounts add Link new Google account
408
+
409
+ ━━━ CLAUDE CODE SETUP ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
410
+ Edit ~/.claude/settings.json:
411
+ {
412
+ "env": {
413
+ "ANTHROPIC_BASE_URL": "http://localhost:8080"
414
+ }
415
+ }
416
+
417
+ Docs: https://github.com/badrisnarayanan/antigravity-claude-proxy
418
+ `);
419
+ }
420
+
421
+ function showVersion() {
422
+ console.log(packageJson.version);
423
+ }
424
+
425
+ async function main() {
426
+ // Handle flags
427
+ if (args.includes('--help') || args.includes('-h')) {
428
+ showHelp();
429
+ process.exit(0);
430
+ }
431
+
432
+ if (args.includes('--version') || args.includes('-v')) {
433
+ showVersion();
434
+ process.exit(0);
435
+ }
436
+
437
+ // Handle commands
438
+ switch (command) {
439
+ case 'start':
440
+ await startServer();
441
+ break;
442
+
443
+ case 'stop':
444
+ stopServer();
445
+ break;
446
+
447
+ case 'restart':
448
+ await restartServer();
449
+ break;
450
+
451
+ case 'status':
452
+ showStatus();
453
+ break;
454
+
455
+ case 'ui':
456
+ await openUI();
457
+ break;
458
+
459
+ case 'accounts': {
460
+ // Pass remaining args to accounts CLI
461
+ const subCommand = args[1] || 'add';
462
+ process.argv = ['node', 'accounts-cli.js', subCommand, ...args.slice(2)];
463
+ await import('../src/cli/accounts.js');
464
+ break;
465
+ }
466
+
467
+ case 'help':
468
+ showHelp();
469
+ break;
470
+
471
+ case 'version':
472
+ showVersion();
473
+ break;
474
+
475
+ case undefined:
476
+ // No command - show help
477
+ showHelp();
478
+ break;
479
+
480
+ default:
481
+ console.error(`Unknown command: ${command}`);
482
+ console.error('Run "acc --help" for usage information.');
483
+ process.exit(1);
484
+ }
485
+ }
486
+
487
+ main().catch((err) => {
488
+ console.error('Error:', err.message);
489
+ process.exit(1);
490
+ });
config.example.json ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "_comment": "Antigravity Claude Proxy Configuration",
3
+ "_instructions": [
4
+ "HOW TO USE THIS FILE:",
5
+ "1. Copy to your HOME directory: ~/.config/antigravity-proxy/config.json",
6
+ " - Windows: C:\\Users\\<username>\\.config\\antigravity-proxy\\config.json",
7
+ " - macOS/Linux: ~/.config/antigravity-proxy/config.json",
8
+ "2. Or copy to project root as 'config.json' (fallback if home config not found)",
9
+ "",
10
+ "NOTE: Environment variables (e.g., WEBUI_PASSWORD) take precedence over file config",
11
+ "Restart server after making changes"
12
+ ],
13
+
14
+ "apiKey": "",
15
+ "_apiKey_comment": "Optional API key to protect /v1/* endpoints. Can also use API_KEY env var.",
16
+
17
+ "webuiPassword": "",
18
+ "_webuiPassword_comment": "Optional password to protect WebUI. Can also use WEBUI_PASSWORD env var.",
19
+
20
+ "port": 8080,
21
+ "debug": false,
22
+ "logLevel": "info",
23
+
24
+ "maxRetries": 5,
25
+ "retryBaseMs": 1000,
26
+ "retryMaxMs": 30000,
27
+
28
+ "defaultCooldownMs": 60000,
29
+ "maxWaitBeforeErrorMs": 120000,
30
+
31
+ "tokenCacheTtlMs": 300000,
32
+ "persistTokenCache": false,
33
+
34
+ "requestTimeoutMs": 300000,
35
+
36
+ "maxAccounts": 10,
37
+ "_maxAccounts_comment": "Maximum number of Google accounts allowed (1-100). Default: 10.",
38
+
39
+ "_oauthCallbackPort_comment": "OAuth callback server port. Change if you get EACCES errors on Windows. Can also use OAUTH_CALLBACK_PORT env var. Default: 51121.",
40
+ "_oauthCallbackPort_env": "OAUTH_CALLBACK_PORT=3456",
41
+
42
+ "_profiles": {
43
+ "development": {
44
+ "debug": true,
45
+ "logLevel": "debug",
46
+ "maxRetries": 3
47
+ },
48
+ "production": {
49
+ "debug": false,
50
+ "logLevel": "info",
51
+ "maxRetries": 5,
52
+ "persistTokenCache": true
53
+ },
54
+ "high-performance": {
55
+ "maxRetries": 10,
56
+ "retryMaxMs": 60000,
57
+ "tokenCacheTtlMs": 600000
58
+ }
59
+ }
60
+ }
docs/api-endpoints.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # API Endpoints
2
+
3
+ | Endpoint | Method | Description |
4
+ | ----------------- | ------ | --------------------------------------------------------------------- |
5
+ | `/health` | GET | Health check |
6
+ | `/account-limits` | GET | Account status and quota limits (add `?format=table` for ASCII table) |
7
+ | `/v1/messages` | POST | Anthropic Messages API |
8
+ | `/v1/models` | GET | List available models |
9
+ | `/refresh-token` | POST | Force token refresh |
docs/configuration.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Advanced Configuration
2
+
3
+ While most users can use the default settings, you can tune the proxy behavior via the **Settings → Server** tab in the WebUI or by creating a `config.json` file.
4
+
5
+ ## Environment Variables
6
+
7
+ The proxy supports the following environment variables:
8
+
9
+ | Variable | Description | Default |
10
+ |----------|-------------|---------|
11
+ | `PORT` | Server port | `8080` |
12
+ | `HOST` | Bind address | `0.0.0.0` |
13
+ | `HTTP_PROXY` | Route outbound requests through a proxy | - |
14
+ | `HTTPS_PROXY` | Same as HTTP_PROXY (for HTTPS requests) | - |
15
+ | `API_KEY` | Protect `/v1/*` API endpoints | - |
16
+ | `WEBUI_PASSWORD` | Password-protect the web dashboard | - |
17
+ | `DEBUG` | Enable debug logging (`true`/`false`) | `false` |
18
+ | `DEV_MODE` | Enable developer mode (`true`/`false`) | `false` |
19
+ | `FALLBACK` | Enable model fallback (`true`/`false`) | `false` |
20
+ | `FALLBACK_ANTIGRAVITY_VERSION` | Override the User-Agent version string (useful when Google APIs reject old versions) | `1.23.2` |
21
+ | `ANTIGRAVITY_CLIENT_VERSION` | Override the X-Client-Version header sent to the API (prevents "no longer supported" errors) | Auto-detected from product.json |
22
+ | `ANTIGRAVITY_CLIENT_VERSION_FALLBACK` | Override the fallback X-Client-Version (used when product.json is not found) | `1.110.0` |
23
+ | `CLAUDE_CONFIG_PATH` | Path to `.claude` directory for CLI settings (for systemd/service deployments) | `~/.claude` |
24
+
25
+ ### Setting Environment Variables
26
+
27
+ #### Inline (single command)
28
+
29
+ Set variables for just one command. Works on macOS, Linux, and Windows with Git Bash/WSL:
30
+
31
+ ```bash
32
+ PORT=3000 HTTP_PROXY=http://proxy:8080 npm start
33
+ ```
34
+
35
+ #### macOS / Linux (persistent)
36
+
37
+ Add to your shell profile (`~/.zshrc` or `~/.bashrc`):
38
+
39
+ ```bash
40
+ export PORT=3000
41
+ export HTTP_PROXY=http://proxy:8080
42
+ ```
43
+
44
+ Then reload: `source ~/.zshrc`
45
+
46
+ #### Windows Command Prompt (persistent)
47
+
48
+ ```cmd
49
+ setx PORT 3000
50
+ setx HTTP_PROXY http://proxy:8080
51
+ ```
52
+
53
+ Restart your terminal for changes to take effect.
54
+
55
+ #### Windows PowerShell (persistent)
56
+
57
+ ```powershell
58
+ [Environment]::SetEnvironmentVariable("PORT", "3000", "User")
59
+ [Environment]::SetEnvironmentVariable("HTTP_PROXY", "http://proxy:8080", "User")
60
+ ```
61
+
62
+ Restart your terminal for changes to take effect.
63
+
64
+ ### HTTP Proxy Support
65
+
66
+ If you're behind a corporate firewall or VPN, you can route all outbound API requests through a proxy server:
67
+
68
+ ```bash
69
+ # Route through a local proxy (e.g., for debugging with mitmproxy)
70
+ HTTP_PROXY=http://127.0.0.1:8888 npm start
71
+
72
+ # Route through a corporate proxy
73
+ HTTP_PROXY=http://proxy.company.com:3128 npm start
74
+
75
+ # With authentication
76
+ HTTP_PROXY=http://user:password@proxy.company.com:3128 npm start
77
+ ```
78
+
79
+ The proxy supports `http_proxy`, `HTTP_PROXY`, `https_proxy`, and `HTTPS_PROXY` (case-insensitive).
80
+
81
+ ## Configurable Options
82
+
83
+ - **API Key Authentication**: Protect `/v1/*` API endpoints with `API_KEY` env var or `apiKey` in config.
84
+ - **WebUI Password**: Secure your dashboard with `WEBUI_PASSWORD` env var or in config.
85
+ - **Custom Port**: Change the default `8080` port.
86
+ - **Retry Logic**: Configure `maxRetries`, `retryBaseMs`, and `retryMaxMs`.
87
+ - **Rate Limit Handling**: Comprehensive rate limit detection from headers and error messages with intelligent retry-after parsing.
88
+ - **Load Balancing**: Adjust `defaultCooldownMs` and `maxWaitBeforeErrorMs`.
89
+ - **Persistence**: Enable `persistTokenCache` to save OAuth sessions across restarts.
90
+ - **Max Accounts**: Set `maxAccounts` (1-100) to limit the number of Google accounts. Default: 10.
91
+ - **Quota Threshold**: Set `globalQuotaThreshold` (0-0.99) to switch accounts before quota drops below a minimum level. Supports per-account and per-model overrides.
92
+ - **Endpoint Fallback**: Automatic 403/404 endpoint fallback for API compatibility.
93
+
94
+ Refer to `config.example.json` for a complete list of fields and documentation.
docs/development.md ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Development
2
+
3
+ ## For Developers & Contributors
4
+
5
+ This project uses a local Tailwind CSS build system. CSS is pre-compiled and included in the repository, so you can run the project immediately after cloning.
6
+
7
+ ### Quick Start
8
+
9
+ ```bash
10
+ git clone https://github.com/badri-s2001/antigravity-claude-proxy.git
11
+ cd antigravity-claude-proxy
12
+ npm install # Automatically builds CSS via prepare hook
13
+ npm start # Start server (no rebuild needed)
14
+ ```
15
+
16
+ ### Frontend Development
17
+
18
+ If you need to modify styles in `public/css/src/input.css`:
19
+
20
+ ```bash
21
+ # Option 1: Build once
22
+ npm run build:css
23
+
24
+ # Option 2: Watch for changes (auto-rebuild)
25
+ npm run watch:css
26
+
27
+ # Option 3: Watch both CSS and server (recommended)
28
+ npm run dev:full
29
+ ```
30
+
31
+ **File Structure:**
32
+ - `public/css/src/input.css` - Source CSS with Tailwind `@apply` directives (edit this)
33
+ - `public/css/style.css` - Compiled & minified CSS (auto-generated, don't edit)
34
+ - `tailwind.config.js` - Tailwind configuration
35
+ - `postcss.config.js` - PostCSS configuration
36
+
37
+ ### Backend-Only Development
38
+
39
+ If you're only working on backend code and don't need frontend dev tools:
40
+
41
+ ```bash
42
+ npm install --production # Skip devDependencies (saves ~20MB)
43
+ npm start
44
+ ```
45
+
46
+ **Note:** Pre-compiled CSS is committed to the repository, so you don't need to rebuild unless modifying styles.
47
+
48
+ ### Project Structure
49
+
50
+ See [CLAUDE.md](../CLAUDE.md) for detailed architecture documentation, including:
51
+ - Request flow and module organization
52
+ - Frontend architecture (Alpine.js + Tailwind)
53
+ - Service layer patterns (`ErrorHandler.withLoading`, `AccountActions`)
54
+ - Dashboard module documentation
docs/legal.md ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ # Legal
2
+
3
+ - **Not affiliated with Google or Anthropic.** This is an independent open-source project and is not endorsed by, sponsored by, or affiliated with Google LLC or Anthropic PBC.
4
+
5
+ - "Antigravity", "Gemini", "Google Cloud", and "Google" are trademarks of Google LLC.
6
+
7
+ - "Claude" and "Anthropic" are trademarks of Anthropic PBC.
8
+
9
+ - Software is provided "as is", without warranty. You are responsible for complying with all applicable Terms of Service and Acceptable Use Policies.
docs/load-balancing.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Multi-Account Load Balancing
2
+
3
+ When you add multiple accounts, the proxy intelligently distributes requests across them using configurable selection strategies.
4
+
5
+ ## Account Selection Strategies
6
+
7
+ Choose a strategy based on your needs:
8
+
9
+ | Strategy | Best For | Description |
10
+ | --- | --- | --- |
11
+ | **Hybrid** (Default) | Most users | Smart selection combining health score, token bucket rate limiting, quota awareness, and LRU freshness |
12
+ | **Sticky** | Prompt caching | Stays on the same account to maximize cache hits, switches only when rate-limited |
13
+ | **Round-Robin** | Even distribution | Cycles through accounts sequentially for balanced load |
14
+
15
+ **Configure via CLI:**
16
+
17
+ ```bash
18
+ antigravity-claude-proxy start --strategy=hybrid # Default: smart distribution
19
+ antigravity-claude-proxy start --strategy=sticky # Cache-optimized
20
+ antigravity-claude-proxy start --strategy=round-robin # Load-balanced
21
+ ```
22
+
23
+ **Or via WebUI:** Settings → Server → Account Selection Strategy
24
+
25
+ ## How It Works
26
+
27
+ - **Health Score Tracking**: Accounts earn points for successful requests and lose points for failures/rate-limits
28
+ - **Token Bucket Rate Limiting**: Client-side throttling with regenerating tokens (50 max, 6/minute)
29
+ - **Quota Awareness**: Accounts below configurable quota thresholds are deprioritized; exhausted accounts trigger emergency fallback
30
+ - **Quota Protection**: Set minimum quota levels globally, per-account, or per-model to switch accounts before quota runs out
31
+ - **Emergency Fallback**: When all accounts appear exhausted, bypasses checks with throttle delays (250-500ms)
32
+ - **Automatic Cooldown**: Rate-limited accounts recover automatically after reset time expires
33
+ - **Invalid Account Detection**: Accounts needing re-authentication are marked and skipped
34
+ - **Prompt Caching Support**: Session IDs derived from conversation enable cache hits across turns
35
+
36
+ ## Monitoring
37
+
38
+ Check account status, subscription tiers, and quota anytime:
39
+
40
+ ```bash
41
+ # Web UI: http://localhost:8080/ (Accounts tab - shows tier badges and quota progress)
42
+ # CLI Table:
43
+ curl "http://localhost:8080/account-limits?format=table"
44
+ ```
45
+
46
+ ### CLI Management Reference
47
+
48
+ If you prefer using the terminal for management:
49
+
50
+ ```bash
51
+ # List all accounts
52
+ antigravity-claude-proxy accounts list
53
+
54
+ # Verify account health
55
+ antigravity-claude-proxy accounts verify
56
+
57
+ # Interactive CLI menu
58
+ antigravity-claude-proxy accounts
59
+ ```
docs/menubar-app.md ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # macOS Menu Bar App
2
+
3
+ For macOS users who prefer a native experience, there's a companion menu bar app that provides quick access to server controls without touching the terminal. Get it from: [antigravity-claude-proxy-bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar)
4
+
5
+ > **Note:** This is a GUI wrapper only. You still need to install and setup the proxy server first using one of the [installation methods](../README.md#installation) above.
6
+
7
+ ![AntiGravity Claude Proxy Bar](https://github.com/IrvanFza/antigravity-claude-proxy-bar/blob/main/images/application.png?raw=true)
8
+
9
+ ## Key Features
10
+
11
+ - **Server Control**: Start/stop the proxy server with a single click or ⌘S shortcut.
12
+ - **Status Indicator**: Menu bar icon shows server running state at a glance.
13
+ - **WebUI Access**: Open the web management console directly from the menu.
14
+ - **Port Configuration**: Customize the proxy server port (default: 8080).
15
+ - **Account Selection Strategy**: Choose between Hybrid, Sticky, or Round-Robin load balancing strategies.
16
+ - **Auto-Start Options**: Launch server on app start and launch app at login.
17
+ - **Native Experience**: Clean, native SwiftUI interface designed for macOS.
docs/models.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Available Models
2
+
3
+ ## Claude Models
4
+
5
+ | Model ID | Description |
6
+ | ---------------------------- | ---------------------------------------- |
7
+ | `claude-sonnet-4-6-thinking` | Claude Sonnet 4.6 with extended thinking |
8
+ | `claude-opus-4-6-thinking` | Claude Opus 4.6 with extended thinking |
9
+ | `claude-sonnet-4-6` | Claude Sonnet 4.6 without thinking |
10
+
11
+ ## Gemini Models
12
+
13
+ | Model ID | Description |
14
+ | ------------------- | ------------------------------- |
15
+ | `gemini-3-flash` | Gemini 3 Flash with thinking |
16
+ | `gemini-3.1-pro-low` | Gemini 3.1 Pro Low with thinking |
17
+ | `gemini-3.1-pro-high` | Gemini 3.1 Pro High with thinking |
18
+
19
+ Gemini models include full thinking support with `thoughtSignature` handling for multi-turn conversations.
docs/openclaw.md ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Using with OpenClaw / ClawdBot
2
+
3
+ [OpenClaw](https://docs.openclaw.ai/) (formerly ClawdBot/Moltbot) is an AI agent gateway that connects to messaging apps like Telegram, WhatsApp, Discord, Slack, and iMessage. You can configure it to use this proxy for Claude and Gemini models.
4
+
5
+ ## Prerequisites
6
+
7
+ - OpenClaw installed (`npm install -g openclaw@latest`)
8
+ - Antigravity Claude Proxy running on port 8080
9
+ - At least one Google account linked to the proxy
10
+
11
+ ## Configure OpenClaw
12
+
13
+ Edit your OpenClaw config file at:
14
+ - **macOS/Linux**: `~/.openclaw/openclaw.json`
15
+ - **Windows**: `%USERPROFILE%\.openclaw\openclaw.json`
16
+
17
+ Add the following configuration:
18
+
19
+ ```json
20
+ {
21
+ "models": {
22
+ "mode": "merge",
23
+ "providers": {
24
+ "antigravity-proxy": {
25
+ "baseUrl": "http://127.0.0.1:8080",
26
+ "apiKey": "test",
27
+ "api": "anthropic-messages",
28
+ "models": [
29
+ {
30
+ "id": "gemini-3-flash",
31
+ "name": "Gemini 3 Flash",
32
+ "reasoning": true,
33
+ "input": ["text", "image"],
34
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
35
+ "contextWindow": 1048576,
36
+ "maxTokens": 65536
37
+ },
38
+ {
39
+ "id": "gemini-3.1-pro-high",
40
+ "name": "Gemini 3.1 Pro High",
41
+ "reasoning": true,
42
+ "input": ["text", "image"],
43
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
44
+ "contextWindow": 1048576,
45
+ "maxTokens": 65536
46
+ },
47
+ {
48
+ "id": "claude-sonnet-4-6",
49
+ "name": "Claude Sonnet 4.6",
50
+ "reasoning": false,
51
+ "input": ["text", "image"],
52
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
53
+ "contextWindow": 200000,
54
+ "maxTokens": 16384
55
+ },
56
+ {
57
+ "id": "claude-sonnet-4-6-thinking",
58
+ "name": "Claude Sonnet 4.6 Thinking",
59
+ "reasoning": true,
60
+ "input": ["text", "image"],
61
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
62
+ "contextWindow": 200000,
63
+ "maxTokens": 16384
64
+ },
65
+ {
66
+ "id": "claude-opus-4-6-thinking",
67
+ "name": "Claude Opus 4.6 Thinking",
68
+ "reasoning": true,
69
+ "input": ["text", "image"],
70
+ "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 },
71
+ "contextWindow": 200000,
72
+ "maxTokens": 32000
73
+ }
74
+ ]
75
+ }
76
+ }
77
+ },
78
+ "agents": {
79
+ "defaults": {
80
+ "model": {
81
+ "primary": "antigravity-proxy/gemini-3-flash",
82
+ "fallbacks": ["antigravity-proxy/gemini-3.1-pro-high"]
83
+ },
84
+ "models": {
85
+ "antigravity-proxy/gemini-3-flash": {}
86
+ }
87
+ }
88
+ }
89
+ }
90
+ ```
91
+
92
+ > **Important**: Use `127.0.0.1` instead of `localhost` in the `baseUrl`. This ensures the connection stays on the loopback interface. If you're running on a VPS and accidentally start the proxy bound to `0.0.0.0`, using `localhost` in your client config could still work but `127.0.0.1` makes the intent explicit and avoids potential DNS resolution issues.
93
+
94
+ ## Start Both Services
95
+
96
+ ```bash
97
+ # Terminal 1: Start the proxy (binds to localhost only by default)
98
+ antigravity-claude-proxy start
99
+
100
+ # Terminal 2: Start OpenClaw gateway
101
+ openclaw gateway
102
+ ```
103
+
104
+ ## Verify Configuration
105
+
106
+ ```bash
107
+ # Check available models
108
+ openclaw models list
109
+
110
+ # Check gateway status
111
+ openclaw status
112
+ ```
113
+
114
+ You should see models prefixed with `antigravity-proxy/` in the list.
115
+
116
+ ## Switch Models
117
+
118
+ To change the default model:
119
+
120
+ ```bash
121
+ openclaw models set antigravity-proxy/claude-opus-4-6-thinking
122
+ ```
123
+
124
+ Or edit the `model.primary` field in your config file.
125
+
126
+ ## Troubleshooting
127
+
128
+ ### Connection Refused
129
+
130
+ Make sure the proxy is running before starting OpenClaw:
131
+ ```bash
132
+ curl http://127.0.0.1:8080/health
133
+ ```
134
+
135
+ ### Models Not Showing
136
+
137
+ 1. Verify the config file is valid JSON
138
+ 2. Check that `mode` is set to `"merge"` (not `"replace"` unless you want to override all built-in models)
139
+ 3. Restart the OpenClaw gateway after config changes
140
+
141
+ ### VPS Security
142
+
143
+ If running on a VPS, ensure the proxy only binds to localhost:
144
+ ```bash
145
+ # Default binds to 0.0.0.0 (all interfaces) - exposed to network!
146
+ antigravity-claude-proxy start
147
+
148
+ # Explicitly bind to localhost only (recommended for VPS)
149
+ HOST=127.0.0.1 antigravity-claude-proxy start
150
+ ```
151
+
152
+ By default, the proxy binds to `0.0.0.0` which exposes it to all network interfaces. On a VPS, always use `HOST=127.0.0.1` to restrict access to localhost only, or ensure you have proper authentication (`API_KEY` env var) and firewall rules in place.
153
+
154
+ ## Further Reading
155
+
156
+ - [OpenClaw Documentation](https://docs.openclaw.ai/)
157
+ - [OpenClaw Configuration Reference](https://docs.openclaw.ai/gateway/configuration)
158
+ - [Proxy Load Balancing](./load-balancing.md)
159
+ - [Proxy Configuration](./configuration.md)
docs/opencode.md ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Using with OpenCode
2
+
3
+ [OpenCode](https://opencode.ai/) is a terminal-based AI coding assistant similar to Claude Code. You can configure it to use this proxy, which unlocks both Claude and Gemini models in a single interactive session.
4
+
5
+ ## The Problem with Claude Code
6
+
7
+ The proxy exposes both Claude and Gemini models via `/v1/models`. However, Claude Code's `/model` picker is hardcoded by Anthropic to only show Claude variants. Gemini models are effectively invisible in the UI.
8
+
9
+ The only workaround within Claude Code is hardcoding a single Gemini model in `settings.json` — no interactive switching, no mid-session changes.
10
+
11
+ ## What OpenCode Solves
12
+
13
+ OpenCode supports custom providers via a config file. Once pointed at this proxy, all models (Claude and Gemini) appear in one interactive picker and can be switched freely at any time.
14
+
15
+ | Feature | Claude Code | OpenCode (with this proxy) |
16
+ |---|---|---|
17
+ | Claude models | yes | yes |
18
+ | Gemini models | hardcoded only | interactive picker |
19
+ | Switch models mid-session | no | yes |
20
+ | Multi-account load balancing | yes (via proxy) | yes (via proxy) |
21
+
22
+ ## Prerequisites
23
+
24
+ - OpenCode installed (`npm install -g opencode-ai@latest`)
25
+ - Antigravity Claude Proxy running on port 8080
26
+ - At least one Google account linked to the proxy
27
+
28
+ ## Global Configuration
29
+
30
+ ```bash
31
+ mkdir -p ~/.config/opencode
32
+
33
+ cat > ~/.config/opencode/opencode.json <<'EOF'
34
+ {
35
+ "$schema": "https://opencode.ai/config.json",
36
+ "provider": {
37
+ "antigravity": {
38
+ "npm": "@ai-sdk/anthropic",
39
+ "name": "Antigravity Local",
40
+ "options": {
41
+ "baseURL": "http://localhost:8080/v1",
42
+ "apiKey": "{env:ANTHROPIC_API_KEY}"
43
+ },
44
+ "models": {
45
+ "claude-sonnet-4-6": { "id": "claude-sonnet-4-6", "name": "Claude Sonnet 4.6" },
46
+ "claude-opus-4-6-thinking": { "id": "claude-opus-4-6-thinking", "name": "Claude Opus 4.6 Thinking" },
47
+ "gemini-2.5-pro": { "id": "gemini-2.5-pro", "name": "Gemini 2.5 Pro" },
48
+ "gemini-2.5-flash": { "id": "gemini-2.5-flash", "name": "Gemini 2.5 Flash" },
49
+ "gemini-2.5-flash-lite": { "id": "gemini-2.5-flash-lite", "name": "Gemini 2.5 Flash Lite" },
50
+ "gemini-2.5-flash-thinking": { "id": "gemini-2.5-flash-thinking", "name": "Gemini 2.5 Flash Thinking" },
51
+ "gemini-3-flash": { "id": "gemini-3-flash", "name": "Gemini 3 Flash" },
52
+ "gemini-3-flash-agent": { "id": "gemini-3-flash-agent", "name": "Gemini 3 Flash Agent" },
53
+ "gemini-pro-agent": { "id": "gemini-pro-agent", "name": "Gemini 3.1 Pro Agent" },
54
+ "gemini-3.1-pro-low": { "id": "gemini-3.1-pro-low", "name": "Gemini 3.1 Pro Low" },
55
+ "gemini-3.1-flash-lite": { "id": "gemini-3.1-flash-lite", "name": "Gemini 3.1 Flash Lite" },
56
+ "gemini-3.1-flash-image": { "id": "gemini-3.1-flash-image", "name": "Gemini 3.1 Flash Image" },
57
+ "gemini-3.5-flash-low": { "id": "gemini-3.5-flash-low", "name": "Gemini 3.5 Flash" }
58
+ }
59
+ }
60
+ },
61
+ "model": "antigravity/claude-sonnet-4-6",
62
+ "small_model": "antigravity/gemini-3.5-flash-low"
63
+ }
64
+ EOF
65
+ ```
66
+
67
+ This config is global and applies automatically every time you run `opencode` from any folder.
68
+
69
+ ## Shell Environment
70
+
71
+ ```bash
72
+ grep -q 'ANTHROPIC_API_KEY=' ~/.zshrc || echo 'export ANTHROPIC_API_KEY="dummy"' >> ~/.zshrc
73
+ grep -q 'ANTHROPIC_BASE_URL=' ~/.zshrc || echo 'export ANTHROPIC_BASE_URL="http://localhost:8080/v1"' >> ~/.zshrc
74
+ source ~/.zshrc
75
+ ```
76
+
77
+ The `ANTHROPIC_API_KEY` value can be anything — the proxy does not validate it. Authentication is handled via your linked Google accounts.
78
+
79
+ ## Start Both Services
80
+
81
+ ```bash
82
+ # Terminal 1: Start the proxy
83
+ antigravity-claude-proxy start
84
+
85
+ # Terminal 2: Open OpenCode from any project folder
86
+ opencode
87
+ ```
88
+
89
+ ## Switching Models
90
+
91
+ Inside OpenCode, all registered models appear as `antigravity/<model-id>`. The `small_model` field handles background operations — setting it to a Gemini Flash model conserves your Claude quota, the same way `ANTHROPIC_DEFAULT_HAIKU_MODEL` works in Claude Code.
92
+
93
+ ## Available Models
94
+
95
+ | Model ID | Type | Best For |
96
+ |---|---|---|
97
+ | `claude-sonnet-4-6` | Claude | Coding, refactoring |
98
+ | `claude-opus-4-6-thinking` | Claude | Architecture, deep reasoning |
99
+ | `gemini-2.5-pro` | Gemini | Long context, analysis |
100
+ | `gemini-2.5-flash` | Gemini | Fast general tasks |
101
+ | `gemini-2.5-flash-lite` | Gemini | Background tasks |
102
+ | `gemini-2.5-flash-thinking` | Gemini | Reasoning tasks |
103
+ | `gemini-3-flash` | Gemini | Fast, lightweight |
104
+ | `gemini-3-flash-agent` | Gemini | Agentic workflows |
105
+ | `gemini-pro-agent` | Gemini | Agentic workflows |
106
+ | `gemini-3.1-pro-low` | Gemini | Strong reasoning, 1M context |
107
+ | `gemini-3.1-flash-lite` | Gemini | Lightweight tasks |
108
+ | `gemini-3.1-flash-image` | Gemini | Vision/image tasks |
109
+ | `gemini-3.5-flash-low` | Gemini | Fast tasks, `small_model` recommended |
110
+
111
+ ## Verify Configuration
112
+
113
+ ```bash
114
+ curl "http://localhost:8080/v1/messages"
115
+ -H "content-type: application/json"
116
+ -H "anthropic-version: 2023-06-01"
117
+ -H "x-api-key: dummy"
118
+ -d '{"model": "claude-sonnet-4-6", "max_tokens": 100, "messages": [{"role":"user","content":"say hi"}]}'
119
+ ```
120
+
121
+ ## Troubleshooting
122
+
123
+ ### `Endpoint POST /messages not found`
124
+
125
+ Your `baseURL` is missing `/v1`. Set it to `http://localhost:8080/v1`, not `http://localhost:8080`.
126
+
127
+ ### Model returns `400 INVALID_ARGUMENT`
128
+
129
+ The model is rejected by Google's backend. Remove it from your `models` block.
130
+
131
+ ### Models not appearing in picker
132
+
133
+ 1. Check the config is valid JSON: `cat ~/.config/opencode/opencode.json`
134
+ 2. Check the env var is set: `echo $ANTHROPIC_API_KEY`
135
+ 3. Open a fresh terminal after config changes
136
+
137
+ ### Connection refused
138
+
139
+ ```bash
140
+ acc status
141
+ curl http://localhost:8080/health
142
+ ```
143
+
144
+ ## Further Reading
145
+
146
+ - [OpenCode Documentation](https://opencode.ai/docs/)
147
+ - [OpenCode Providers](https://opencode.ai/docs/providers/)
148
+ - [Proxy Load Balancing](load-balancing.md)
149
+ - [Proxy Configuration](configuration.md)
150
+ - [Available Models](models.md)
docs/safety-notices.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Safety, Usage, and Risk Notices
2
+
3
+ ## Intended Use
4
+
5
+ - Personal / internal development only
6
+ - Respect internal quotas and data handling policies
7
+ - Not for production services or bypassing intended limits
8
+
9
+ ## Not Suitable For
10
+
11
+ - Production application traffic
12
+ - High-volume automated extraction
13
+ - Any use that violates Acceptable Use Policies
14
+
15
+ ## Warning (Assumption of Risk)
16
+
17
+ By using this software, you acknowledge and accept the following:
18
+
19
+ - **Terms of Service risk**: This approach may violate the Terms of Service of AI model providers (Anthropic, Google, etc.). You are solely responsible for ensuring compliance with all applicable terms and policies.
20
+
21
+ - **Account risk**: Providers may detect this usage pattern and take punitive action, including suspension, permanent ban, or loss of access to paid subscriptions.
22
+
23
+ - **No guarantees**: Providers may change APIs, authentication, or policies at any time, which can break this method without notice.
24
+
25
+ - **Assumption of risk**: You assume all legal, financial, and technical risks. The authors and contributors of this project bear no responsibility for any consequences arising from your use.
26
+
27
+ **Use at your own risk. Proceed only if you understand and accept these risks.**
docs/testing.md ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Testing
2
+
3
+ Run the test suite (requires server running):
4
+
5
+ ```bash
6
+ # Start server in one terminal
7
+ npm start
8
+
9
+ # Run tests in another terminal
10
+ npm test
11
+ ```
12
+
13
+ Individual tests:
14
+
15
+ ```bash
16
+ npm run test:signatures # Thinking signatures
17
+ npm run test:multiturn # Multi-turn with tools
18
+ npm run test:streaming # Streaming SSE events
19
+ npm run test:interleaved # Interleaved thinking
20
+ npm run test:images # Image processing
21
+ npm run test:caching # Prompt caching
22
+ npm run test:strategies # Account selection strategies
23
+ npm run test:cache-control # Cache control field stripping
24
+ ```
docs/troubleshooting.md ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Troubleshooting
2
+
3
+ ## Quick Links
4
+
5
+ - [Windows: OAuth Port Error (EACCES)](#windows-oauth-port-error-eacces)
6
+ - ["Could not extract token from Antigravity"](#could-not-extract-token-from-antigravity)
7
+ - [401 Authentication Errors](#401-authentication-errors)
8
+ - [Rate Limiting (429)](#rate-limiting-429)
9
+ - [Account Shows as "Invalid"](#account-shows-as-invalid)
10
+ - [403 Permission Denied / VALIDATION_REQUIRED](#403-permission-denied--validation_required)
11
+
12
+ ---
13
+
14
+ ## Windows: OAuth Port Error (EACCES)
15
+
16
+ On Windows, the default OAuth callback port (51121) may be reserved by Hyper-V, WSL2, or Docker. If you see:
17
+
18
+ ```
19
+ Error: listen EACCES: permission denied 0.0.0.0:51121
20
+ ```
21
+
22
+ The proxy will automatically try fallback ports (51122-51126). If all ports fail, try these solutions:
23
+
24
+ ### Option 1: Use a Custom Port (Recommended)
25
+
26
+ Set a custom port outside the reserved range:
27
+
28
+ ```bash
29
+ # Windows PowerShell
30
+ $env:OAUTH_CALLBACK_PORT = "3456"
31
+ antigravity-claude-proxy start
32
+
33
+ # Windows CMD
34
+ set OAUTH_CALLBACK_PORT=3456
35
+ antigravity-claude-proxy start
36
+
37
+ # Or add to your .env file
38
+ OAUTH_CALLBACK_PORT=3456
39
+ ```
40
+
41
+ ### Option 2: Reset Windows NAT
42
+
43
+ Run as Administrator:
44
+
45
+ ```powershell
46
+ net stop winnat
47
+ net start winnat
48
+ ```
49
+
50
+ ### Option 3: Check Reserved Ports
51
+
52
+ See which ports are reserved:
53
+
54
+ ```powershell
55
+ netsh interface ipv4 show excludedportrange protocol=tcp
56
+ ```
57
+
58
+ If 51121 is in a reserved range, use Option 1 with a port outside those ranges.
59
+
60
+ ### Option 4: Permanently Exclude Port (Admin)
61
+
62
+ Reserve the port before Hyper-V claims it (run as Administrator):
63
+
64
+ ```powershell
65
+ netsh int ipv4 add excludedportrange protocol=tcp startport=51121 numberofports=1
66
+ ```
67
+
68
+ > **Note:** The server automatically tries fallback ports (51122-51126) if the primary port fails.
69
+
70
+ ---
71
+
72
+ ## "Could not extract token from Antigravity"
73
+
74
+ If using single-account mode with Antigravity:
75
+
76
+ 1. Make sure Antigravity app is installed and running
77
+ 2. Ensure you're logged in to Antigravity
78
+
79
+ Or add accounts via OAuth instead: `antigravity-claude-proxy accounts add`
80
+
81
+ ## 401 Authentication Errors
82
+
83
+ The token might have expired. Try:
84
+
85
+ ```bash
86
+ curl -X POST http://localhost:8080/refresh-token
87
+ ```
88
+
89
+ Or re-authenticate the account:
90
+
91
+ ```bash
92
+ antigravity-claude-proxy accounts
93
+ ```
94
+
95
+ ## Rate Limiting (429)
96
+
97
+ With multiple accounts, the proxy automatically switches to the next available account. With a single account, you'll need to wait for the rate limit to reset.
98
+
99
+ ## Account Shows as "Invalid"
100
+
101
+ Re-authenticate the account:
102
+
103
+ ```bash
104
+ antigravity-claude-proxy accounts
105
+ # Choose "Re-authenticate" for the invalid account
106
+ ```
107
+
108
+ ## 403 Permission Denied / VALIDATION_REQUIRED
109
+
110
+ If you see:
111
+
112
+ ```
113
+ 403 VALIDATION_REQUIRED - Account requires verification
114
+ ```
115
+
116
+ This means Google requires your account to complete verification (phone number, captcha, or terms acceptance).
117
+
118
+ **The proxy handles this automatically:**
119
+
120
+ 1. The affected account is marked invalid and the proxy rotates to the next available account
121
+ 2. If a verification URL is provided by Google, it's stored and shown in the WebUI
122
+ 3. Other accounts continue working normally while the affected account is paused
123
+
124
+ **To fix the affected account:**
125
+
126
+ 1. Open the WebUI (http://localhost:8080)
127
+ 2. Find the account marked with an error badge
128
+ 3. Click the **FIX** button — this opens the Google verification page directly
129
+ 4. Complete the verification (phone number, captcha, etc.)
130
+ 5. Click the **↻ Refresh** button on the account to re-enable it
131
+
132
+ **If the FIX button opens an OAuth page instead** (no verification URL was provided), re-authenticate the account:
133
+
134
+ ```bash
135
+ antigravity-claude-proxy accounts
136
+ # Choose "Re-authenticate" for the invalid account
137
+ ```
138
+
139
+ **If all accounts are invalid**, the proxy returns an error immediately instead of waiting indefinitely:
140
+
141
+ ```
142
+ All accounts are invalid: Account requires verification. Visit the WebUI to fix them.
143
+ ```
144
+
145
+ > **Note:** Verification errors persist across server restarts until resolved. Auth errors (token revoked/expired) are reset on restart and require OAuth re-authentication via the FIX button.
docs/web-console.md ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web Management Console
2
+
3
+ The proxy includes a built-in, modern web interface for real-time monitoring and configuration. Access the console at: `http://localhost:8080` (default port).
4
+
5
+ ![Antigravity Console](../images/webui-dashboard.png)
6
+
7
+ ## Key Features
8
+
9
+ - **Real-time Dashboard**: Monitor request volume, active accounts, model health, and subscription tier distribution.
10
+ - **Visual Model Quota**: Track per-model usage and next reset times with color-coded progress indicators and draggable per-account threshold markers.
11
+ - **Account Management**: Add/remove Google accounts via OAuth, view subscription tiers (Free/Pro/Ultra), quota status, and per-account threshold settings.
12
+ - **Manual OAuth Mode**: Add accounts on headless servers by copying the OAuth URL and pasting the authorization code.
13
+ - **Claude CLI Configuration**: Edit your `~/.claude/settings.json` directly from the browser.
14
+ - **Persistent History**: Tracks request volume by model family for 30 days, persisting across server restarts.
15
+ - **Time Range Filtering**: Analyze usage trends over 1H, 6H, 24H, 7D, or All Time periods.
16
+ - **Smart Analysis**: Auto-select top 5 most used models or toggle between Family/Model views.
17
+ - **Live Logs**: Stream server logs with level-based filtering and search.
18
+ - **Quota Protection**: Set global or per-account minimum quota thresholds to proactively switch accounts before quota runs out.
19
+ - **Advanced Tuning**: Configure retries, timeouts, and debug mode on the fly.
20
+ - **Multi-language Interface**: Full support for English, Chinese (中文), Indonesian (Bahasa), Portuguese (PT-BR), and Turkish (Türkçe).
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "antigravity-claude-proxy",
3
+ "version": "2.7.7",
4
+ "description": "Proxy server to use Antigravity's Claude models with Claude Code CLI",
5
+ "main": "src/index.js",
6
+ "type": "module",
7
+ "bin": {
8
+ "antigravity-claude-proxy": "bin/cli.js",
9
+ "acc": "bin/cli.js"
10
+ },
11
+ "files": [
12
+ "src",
13
+ "bin",
14
+ "public"
15
+ ],
16
+ "scripts": {
17
+ "build:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --minify",
18
+ "watch:css": "tailwindcss -i ./public/css/src/input.css -o ./public/css/style.css --watch",
19
+ "prepare": "npm run build:css",
20
+ "start": "node src/index.js",
21
+ "dev": "node --watch src/index.js",
22
+ "dev:full": "concurrently \"npm run watch:css\" \"npm run dev\"",
23
+ "accounts": "node src/cli/accounts.js",
24
+ "accounts:add": "node src/cli/accounts.js add",
25
+ "accounts:list": "node src/cli/accounts.js list",
26
+ "accounts:remove": "node src/cli/accounts.js remove",
27
+ "accounts:verify": "node src/cli/accounts.js verify",
28
+ "test": "node tests/run-all.cjs",
29
+ "test:signatures": "node tests/test-thinking-signatures.cjs",
30
+ "test:multiturn": "node tests/test-multiturn-thinking-tools.cjs",
31
+ "test:streaming": "node tests/test-multiturn-thinking-tools-streaming.cjs",
32
+ "test:interleaved": "node tests/test-interleaved-thinking.cjs",
33
+ "test:images": "node tests/test-images.cjs",
34
+ "test:caching": "node tests/test-caching-streaming.cjs",
35
+ "test:crossmodel": "node tests/test-cross-model-thinking.cjs",
36
+ "test:oauth": "node tests/test-oauth-no-browser.cjs",
37
+ "test:emptyretry": "node tests/test-empty-response-retry.cjs",
38
+ "test:sanitizer": "node tests/test-schema-sanitizer.cjs",
39
+ "test:strategies": "node tests/test-strategies.cjs",
40
+ "test:cache-control": "node tests/test-cache-control.cjs",
41
+ "test:migration": "node --test tests/migration/*.test.js"
42
+ },
43
+ "keywords": [
44
+ "claude",
45
+ "anthropic",
46
+ "antigravity",
47
+ "proxy",
48
+ "vertex-ai"
49
+ ],
50
+ "author": "Badri Narayanan",
51
+ "license": "MIT",
52
+ "repository": {
53
+ "type": "git",
54
+ "url": "git+https://github.com/badri-s2001/antigravity-claude-proxy.git"
55
+ },
56
+ "homepage": "https://github.com/badri-s2001/antigravity-claude-proxy#readme",
57
+ "bugs": {
58
+ "url": "https://github.com/badri-s2001/antigravity-claude-proxy/issues"
59
+ },
60
+ "engines": {
61
+ "node": ">=20.0.0"
62
+ },
63
+ "dependencies": {
64
+ "async-mutex": "^0.5.0",
65
+ "better-sqlite3": "^12.5.0",
66
+ "cors": "^2.8.5",
67
+ "express": "^4.18.2",
68
+ "undici": "^7.20.0"
69
+ },
70
+ "devDependencies": {
71
+ "@tailwindcss/forms": "^0.5.7",
72
+ "autoprefixer": "^10.4.16",
73
+ "concurrently": "^8.2.2",
74
+ "daisyui": "^4.12.14",
75
+ "postcss": "^8.4.32",
76
+ "tailwindcss": "^3.4.0"
77
+ }
78
+ }
postcss.config.js ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ export default {
2
+ plugins: {
3
+ tailwindcss: {},
4
+ autoprefixer: {}
5
+ }
6
+ }
public/app.js ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Antigravity Console - Main Entry
3
+ *
4
+ * This file orchestrates Alpine.js initialization.
5
+ * Components are loaded via separate script files that register themselves
6
+ * to window.Components before this script runs.
7
+ */
8
+
9
+ document.addEventListener('alpine:init', () => {
10
+ // Register Components (loaded from separate files via window.Components)
11
+ Alpine.data('dashboard', window.Components.dashboard);
12
+ Alpine.data('models', window.Components.models);
13
+ Alpine.data('accountManager', window.Components.accountManager);
14
+ Alpine.data('claudeConfig', window.Components.claudeConfig);
15
+ Alpine.data('logsViewer', window.Components.logsViewer);
16
+ Alpine.data('addAccountModal', window.Components.addAccountModal);
17
+
18
+ // View Loader Directive
19
+ Alpine.directive('load-view', (el, { expression }, { evaluate }) => {
20
+ if (!window.viewCache) window.viewCache = new Map();
21
+
22
+ // Evaluate the expression to get the actual view name (removes quotes)
23
+ const viewName = evaluate(expression);
24
+
25
+ if (window.viewCache.has(viewName)) {
26
+ el.innerHTML = window.viewCache.get(viewName);
27
+ Alpine.initTree(el);
28
+ return;
29
+ }
30
+
31
+ fetch(`views/${viewName}.html?t=${Date.now()}`)
32
+ .then(response => {
33
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
34
+ return response.text();
35
+ })
36
+ .then(html => {
37
+ // Update cache (optional, or remove if we want always-fresh)
38
+ // keeping cache for session performance, but initial load will now bypass browser cache
39
+ window.viewCache.set(viewName, html);
40
+ el.innerHTML = html;
41
+ Alpine.initTree(el);
42
+ })
43
+ .catch(err => {
44
+ console.error('Failed to load view:', viewName, err);
45
+ el.innerHTML = `<div class="p-4 border border-red-500/50 bg-red-500/10 rounded-lg text-red-400 font-mono text-sm">
46
+ Error loading view: ${viewName}<br>
47
+ <span class="text-xs opacity-75">${err.message}</span>
48
+ </div>`;
49
+ });
50
+ });
51
+
52
+ // Main App Controller
53
+ Alpine.data('app', () => ({
54
+ get connectionStatus() {
55
+ return Alpine.store('data')?.connectionStatus || 'connecting';
56
+ },
57
+ get loading() {
58
+ return Alpine.store('data')?.loading || false;
59
+ },
60
+
61
+ sidebarOpen: window.innerWidth >= 1024,
62
+ toggleSidebar() {
63
+ this.sidebarOpen = !this.sidebarOpen;
64
+ },
65
+
66
+ init() {
67
+ // Handle responsive sidebar transitions
68
+ let lastWidth = window.innerWidth;
69
+ let resizeTimeout = null;
70
+
71
+ window.addEventListener('resize', () => {
72
+ if (resizeTimeout) clearTimeout(resizeTimeout);
73
+
74
+ resizeTimeout = setTimeout(() => {
75
+ const currentWidth = window.innerWidth;
76
+ const lgBreakpoint = 1024;
77
+
78
+ // Desktop -> Mobile: Auto-close sidebar to prevent overlay blocking screen
79
+ if (lastWidth >= lgBreakpoint && currentWidth < lgBreakpoint) {
80
+ this.sidebarOpen = false;
81
+ }
82
+
83
+ // Mobile -> Desktop: Auto-open sidebar (restore standard desktop layout)
84
+ if (lastWidth < lgBreakpoint && currentWidth >= lgBreakpoint) {
85
+ this.sidebarOpen = true;
86
+ }
87
+
88
+ lastWidth = currentWidth;
89
+ }, 150);
90
+ });
91
+
92
+ // Theme setup
93
+ document.documentElement.setAttribute('data-theme', 'black');
94
+ document.documentElement.classList.add('dark');
95
+
96
+ // Chart Defaults
97
+ if (typeof Chart !== 'undefined') {
98
+ Chart.defaults.color = window.utils.getThemeColor('--color-text-dim');
99
+ Chart.defaults.borderColor = window.utils.getThemeColor('--color-space-border');
100
+ Chart.defaults.font.family = '"JetBrains Mono", monospace';
101
+ }
102
+
103
+ // Start Data Polling
104
+ this.startAutoRefresh();
105
+ document.addEventListener('refresh-interval-changed', () => this.startAutoRefresh());
106
+
107
+ // Initial Fetch
108
+ Alpine.store('data').fetchData();
109
+ },
110
+
111
+ refreshTimer: null,
112
+
113
+ fetchData() {
114
+ Alpine.store('data').fetchData();
115
+ },
116
+
117
+ startAutoRefresh() {
118
+ if (this.refreshTimer) clearInterval(this.refreshTimer);
119
+ const interval = parseInt(Alpine.store('settings')?.refreshInterval || 60);
120
+ if (interval > 0) {
121
+ this.refreshTimer = setInterval(() => Alpine.store('data').fetchData(), interval * 1000);
122
+ }
123
+ },
124
+
125
+ t(key) {
126
+ return Alpine.store('global')?.t(key) || key;
127
+ },
128
+
129
+ async addAccountWeb(reAuthEmail = null) {
130
+ const password = Alpine.store('global').webuiPassword;
131
+ try {
132
+ const urlPath = reAuthEmail
133
+ ? `/api/auth/url?email=${encodeURIComponent(reAuthEmail)}`
134
+ : '/api/auth/url';
135
+
136
+ const { response, newPassword } = await window.utils.request(urlPath, {}, password);
137
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
138
+
139
+ const data = await response.json();
140
+
141
+ if (data.status === 'ok') {
142
+ // Show info toast that OAuth is in progress
143
+ Alpine.store('global').showToast(Alpine.store('global').t('oauthInProgress'), 'info');
144
+
145
+ // Open OAuth window
146
+ const oauthWindow = window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
147
+
148
+ // Poll for account changes instead of relying on postMessage
149
+ // (since OAuth callback is now on port 51121, not this server)
150
+ const initialAccountCount = Alpine.store('data').accounts.length;
151
+ let pollCount = 0;
152
+ const maxPolls = 60; // 2 minutes (2 second intervals)
153
+ let cancelled = false;
154
+
155
+ // Show progress modal
156
+ Alpine.store('global').oauthProgress = {
157
+ active: true,
158
+ current: 0,
159
+ max: maxPolls,
160
+ cancel: () => {
161
+ cancelled = true;
162
+ clearInterval(pollInterval);
163
+ Alpine.store('global').oauthProgress.active = false;
164
+ Alpine.store('global').showToast(Alpine.store('global').t('oauthCancelled'), 'info');
165
+ if (oauthWindow && !oauthWindow.closed) {
166
+ oauthWindow.close();
167
+ }
168
+ }
169
+ };
170
+
171
+ const pollInterval = setInterval(async () => {
172
+ if (cancelled) {
173
+ clearInterval(pollInterval);
174
+ return;
175
+ }
176
+
177
+ pollCount++;
178
+ Alpine.store('global').oauthProgress.current = pollCount;
179
+
180
+ // Check if OAuth window was closed manually
181
+ if (oauthWindow && oauthWindow.closed && !cancelled) {
182
+ clearInterval(pollInterval);
183
+ Alpine.store('global').oauthProgress.active = false;
184
+ Alpine.store('global').showToast(Alpine.store('global').t('oauthWindowClosed'), 'warning');
185
+ return;
186
+ }
187
+
188
+ // Refresh account list
189
+ await Alpine.store('data').fetchData();
190
+
191
+ // Check if new account was added
192
+ const currentAccountCount = Alpine.store('data').accounts.length;
193
+ if (currentAccountCount > initialAccountCount) {
194
+ clearInterval(pollInterval);
195
+ Alpine.store('global').oauthProgress.active = false;
196
+
197
+ const actionKey = reAuthEmail ? 'accountReauthSuccess' : 'accountAddedSuccess';
198
+ Alpine.store('global').showToast(
199
+ Alpine.store('global').t(actionKey),
200
+ 'success'
201
+ );
202
+ document.getElementById('add_account_modal')?.close();
203
+
204
+ if (oauthWindow && !oauthWindow.closed) {
205
+ oauthWindow.close();
206
+ }
207
+ }
208
+
209
+ // Stop polling after max attempts
210
+ if (pollCount >= maxPolls) {
211
+ clearInterval(pollInterval);
212
+ Alpine.store('global').oauthProgress.active = false;
213
+ Alpine.store('global').showToast(
214
+ Alpine.store('global').t('oauthTimeout'),
215
+ 'warning'
216
+ );
217
+ }
218
+ }, 2000); // Poll every 2 seconds
219
+ } else {
220
+ Alpine.store('global').showToast(data.error || Alpine.store('global').t('failedToGetAuthUrl'), 'error');
221
+ }
222
+ } catch (e) {
223
+ Alpine.store('global').showToast(Alpine.store('global').t('failedToStartOAuth') + ': ' + e.message, 'error');
224
+ }
225
+ }
226
+ }));
227
+ });
public/css/src/input.css ADDED
@@ -0,0 +1,523 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
4
+
5
+ :root {
6
+ /* === Background Layers === */
7
+ --color-space-950: #09090b;
8
+ --color-space-900: #0f0f11;
9
+ --color-space-850: #121214;
10
+ --color-space-800: #18181b;
11
+ --color-space-border: #27272a;
12
+
13
+ /* === Neon Accents (Full Saturation) === */
14
+ --color-neon-purple: #a855f7;
15
+ --color-neon-green: #22c55e;
16
+ --color-neon-cyan: #06b6d4;
17
+ --color-neon-yellow: #eab308;
18
+ --color-neon-red: #ef4444;
19
+
20
+ /* === Soft Neon (Reduced Saturation for Fills) === */
21
+ --color-neon-purple-soft: #9333ea;
22
+ --color-neon-green-soft: #16a34a;
23
+ --color-neon-cyan-soft: #0891b2;
24
+
25
+ /* === Text Hierarchy (WCAG AA Compliant) === */
26
+ --color-text-primary: #ffffff; /* Emphasis: Titles, Key Numbers */
27
+ --color-text-secondary: #d4d4d8; /* Content: Body Text (zinc-300) */
28
+ --color-text-tertiary: #a1a1aa; /* Metadata: Timestamps, Labels (zinc-400) */
29
+ --color-text-quaternary: #71717a; /* Subtle: Decorative (zinc-500) */
30
+
31
+ /* === Legacy Aliases (Backward Compatibility) === */
32
+ --color-text-main: var(--color-text-secondary);
33
+ --color-text-dim: var(--color-text-tertiary);
34
+ --color-text-muted: var(--color-text-tertiary);
35
+ --color-text-bright: var(--color-text-primary);
36
+
37
+ /* Gradient Accents */
38
+ --color-green-400: #4ade80;
39
+ --color-yellow-400: #facc15;
40
+ --color-red-400: #f87171;
41
+
42
+ /* Chart Colors */
43
+ --color-chart-1: #a855f7;
44
+ --color-chart-2: #c084fc;
45
+ --color-chart-3: #e879f9;
46
+ --color-chart-4: #d946ef;
47
+ --color-chart-5: #22c55e;
48
+ --color-chart-6: #4ade80;
49
+ --color-chart-7: #86efac;
50
+ --color-chart-8: #10b981;
51
+ --color-chart-9: #06b6d4;
52
+ --color-chart-10: #f59e0b;
53
+ --color-chart-11: #ef4444;
54
+ --color-chart-12: #ec4899;
55
+ --color-chart-13: #8b5cf6;
56
+ --color-chart-14: #14b8a6;
57
+ --color-chart-15: #f97316;
58
+ --color-chart-16: #6366f1;
59
+ }
60
+
61
+ [x-cloak] {
62
+ display: none !important;
63
+ }
64
+
65
+ /* Custom Scrollbar */
66
+ .custom-scrollbar {
67
+ scrollbar-gutter: stable;
68
+ }
69
+
70
+ ::-webkit-scrollbar {
71
+ width: 8px;
72
+ height: 8px;
73
+ }
74
+
75
+ ::-webkit-scrollbar-track {
76
+ background: rgba(9, 9, 11, 0.3);
77
+ border-radius: 4px;
78
+ }
79
+
80
+ ::-webkit-scrollbar-thumb {
81
+ background: linear-gradient(180deg, #27272a 0%, #18181b 100%);
82
+ border-radius: 4px;
83
+ border: 1px solid rgba(255, 255, 255, 0.05);
84
+ transition: background 0.2s ease;
85
+ }
86
+
87
+ ::-webkit-scrollbar-thumb:hover {
88
+ background: linear-gradient(180deg, #3f3f46 0%, #27272a 100%);
89
+ border-color: rgba(168, 85, 247, 0.3);
90
+ }
91
+
92
+ /* Animations */
93
+ .fade-enter-active,
94
+ .fade-leave-active {
95
+ transition: opacity 0.2s ease;
96
+ }
97
+
98
+ .fade-enter-from,
99
+ .fade-leave-to {
100
+ opacity: 0;
101
+ }
102
+
103
+ @keyframes fadeIn {
104
+ from {
105
+ opacity: 0;
106
+ transform: translateY(5px);
107
+ }
108
+ to {
109
+ opacity: 1;
110
+ transform: translateY(0);
111
+ }
112
+ }
113
+
114
+ .animate-fade-in {
115
+ animation: fadeIn 0.4s ease-out forwards;
116
+ }
117
+
118
+ .nav-item.active {
119
+ background: linear-gradient(
120
+ 90deg,
121
+ theme("colors.neon.purple / 15%") 0%,
122
+ transparent 100%
123
+ );
124
+ @apply border-l-4 border-neon-purple text-white;
125
+ }
126
+
127
+ .nav-item {
128
+ @apply border-l-4 border-transparent transition-all duration-200;
129
+ }
130
+
131
+ .progress-gradient-success::-webkit-progress-value {
132
+ background-image: linear-gradient(
133
+ to right,
134
+ var(--color-neon-green),
135
+ var(--color-green-400)
136
+ );
137
+ }
138
+
139
+ .progress-gradient-warning::-webkit-progress-value {
140
+ background-image: linear-gradient(
141
+ to right,
142
+ var(--color-neon-yellow),
143
+ var(--color-yellow-400)
144
+ );
145
+ }
146
+
147
+ .progress-gradient-error::-webkit-progress-value {
148
+ background-image: linear-gradient(
149
+ to right,
150
+ var(--color-neon-red),
151
+ var(--color-red-400)
152
+ );
153
+ }
154
+
155
+ /* Dashboard Grid */
156
+ .stats-grid {
157
+ display: grid;
158
+ grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
159
+ gap: 1.5rem;
160
+ }
161
+
162
+ /* Tooltip Customization */
163
+ .tooltip:before {
164
+ @apply bg-space-800 border border-space-border text-gray-200 font-mono text-xs;
165
+ }
166
+
167
+ .tooltip-left:before {
168
+ margin-right: 0.5rem;
169
+ }
170
+
171
+ /* -------------------------------------------------------------------------- */
172
+ /* Refactored Global Utilities */
173
+ /* -------------------------------------------------------------------------- */
174
+
175
+ /* Standard Layout Constants */
176
+ :root {
177
+ --view-padding: 2rem; /* 32px - Standard Padding */
178
+ --view-gap: 2rem; /* 32px - Standard component gap */
179
+ --card-radius: 0.75rem; /* 12px */
180
+ }
181
+
182
+ @media (max-width: 768px) {
183
+ :root {
184
+ --view-padding: 1rem;
185
+ --view-gap: 1.25rem;
186
+ }
187
+ }
188
+
189
+ /* Base View Container */
190
+ .view-container {
191
+ display: flex;
192
+ flex-direction: column;
193
+ margin-left: auto;
194
+ margin-right: auto;
195
+ width: 100%;
196
+ padding: var(--view-padding);
197
+ gap: var(--view-gap);
198
+ min-height: calc(100vh - 56px); /* Align with navbar height */
199
+ max-width: 1400px;
200
+ scrollbar-gutter: stable;
201
+ }
202
+
203
+ /* Specialized container for data-heavy pages (Logs) */
204
+ .view-container-full {
205
+ @apply w-full animate-fade-in flex flex-col;
206
+ padding: var(--view-padding);
207
+ gap: var(--view-gap);
208
+ min-height: calc(100vh - 56px);
209
+ max-width: 100%;
210
+ }
211
+
212
+ /* Centered container for form-heavy pages (Settings/Accounts) */
213
+ .view-container-centered {
214
+ @apply mx-auto w-full animate-fade-in flex flex-col;
215
+ padding: var(--view-padding);
216
+ gap: var(--view-gap);
217
+ min-height: calc(100vh - 56px);
218
+ max-width: 900px; /* Comfortable reading width for forms */
219
+ }
220
+
221
+ /* Standard Section Header */
222
+ .view-header {
223
+ display: flex;
224
+ flex-direction: column;
225
+ justify-content: space-between;
226
+ margin-bottom: 0.5rem;
227
+ gap: 1rem;
228
+ }
229
+
230
+ @media (min-width: 768px) {
231
+ .view-header {
232
+ flex-direction: row;
233
+ align-items: flex-end;
234
+ }
235
+ }
236
+
237
+ .view-header-title {
238
+ @apply flex flex-col;
239
+ }
240
+
241
+ .view-header-title h2 {
242
+ @apply text-2xl font-bold text-white tracking-tight;
243
+ }
244
+
245
+ .view-header-title p {
246
+ @apply text-sm text-gray-500 mt-1;
247
+ }
248
+
249
+ .view-header-actions {
250
+ @apply flex items-center gap-3;
251
+ }
252
+
253
+ /* Standard Card Panel */
254
+ .view-card {
255
+ position: relative;
256
+ overflow: hidden;
257
+ border-radius: var(--card-radius);
258
+ padding: 1.5rem;
259
+ border: 1px solid rgba(255, 255, 255, 0.08);
260
+ background: linear-gradient(135deg,
261
+ rgba(15, 15, 17, 0.75) 0%,
262
+ rgba(18, 18, 20, 0.70) 100%
263
+ );
264
+ -webkit-backdrop-filter: blur(12px);
265
+ backdrop-filter: blur(12px);
266
+ box-shadow:
267
+ 0 0 0 1px rgba(255, 255, 255, 0.02) inset,
268
+ 0 4px 24px rgba(0, 0, 0, 0.4);
269
+ transition: border-color 0.3s ease, box-shadow 0.3s ease;
270
+ }
271
+
272
+ .view-card:hover {
273
+ border-color: rgba(255, 255, 255, 0.12);
274
+ box-shadow:
275
+ 0 0 0 1px rgba(255, 255, 255, 0.04) inset,
276
+ 0 8px 32px rgba(0, 0, 0, 0.5);
277
+ }
278
+
279
+ .view-card-header {
280
+ @apply flex items-center justify-between mb-4 pb-4 border-b border-[rgba(39,39,42,0.3)];
281
+ }
282
+
283
+ /* Component Unification */
284
+ .standard-table {
285
+ @apply table w-full border-separate border-spacing-0;
286
+ }
287
+ .standard-table thead {
288
+ @apply bg-space-900/50 text-gray-500 font-mono text-xs uppercase border-b border-space-border;
289
+ }
290
+ .standard-table tbody tr {
291
+ @apply transition-all duration-200 border-b border-[rgba(39,39,42,0.3)] last:border-0;
292
+ }
293
+
294
+ .standard-table tbody tr:hover {
295
+ background: linear-gradient(
296
+ 90deg,
297
+ rgba(255, 255, 255, 0.03) 0%,
298
+ rgba(255, 255, 255, 0.05) 50%,
299
+ rgba(255, 255, 255, 0.03) 100%
300
+ );
301
+ border-color: rgba(255, 255, 255, 0.08);
302
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
303
+ }
304
+
305
+ /* Custom Range Slider - Simplified */
306
+ .custom-range {
307
+ -webkit-appearance: none;
308
+ appearance: none;
309
+ width: 100%;
310
+ height: 4px;
311
+ background: var(--color-space-800);
312
+ border-radius: 999px;
313
+ outline: none;
314
+ cursor: pointer;
315
+ }
316
+
317
+ .custom-range::-webkit-slider-thumb {
318
+ -webkit-appearance: none;
319
+ appearance: none;
320
+ width: 14px;
321
+ height: 14px;
322
+ border-radius: 50%;
323
+ background: var(--range-color, var(--color-neon-purple));
324
+ cursor: pointer;
325
+ transition: transform 0.1s ease;
326
+ }
327
+
328
+ .custom-range::-webkit-slider-thumb:hover {
329
+ transform: scale(1.15);
330
+ }
331
+
332
+ .custom-range::-moz-range-thumb {
333
+ width: 14px;
334
+ height: 14px;
335
+ border: none;
336
+ border-radius: 50%;
337
+ background: var(--range-color, var(--color-neon-purple));
338
+ cursor: pointer;
339
+ transition: transform 0.1s ease;
340
+ }
341
+
342
+ .custom-range::-moz-range-thumb:hover {
343
+ transform: scale(1.15);
344
+ }
345
+
346
+ /* Color Variants */
347
+ .custom-range-purple {
348
+ --range-color: var(--color-neon-purple);
349
+ }
350
+ .custom-range-green {
351
+ --range-color: var(--color-neon-green);
352
+ }
353
+ .custom-range-cyan {
354
+ --range-color: var(--color-neon-cyan);
355
+ }
356
+ .custom-range-yellow {
357
+ --range-color: var(--color-neon-yellow);
358
+ }
359
+ .custom-range-accent {
360
+ --range-color: var(--color-neon-cyan);
361
+ }
362
+
363
+ /* -------------------------------------------------------------------------- */
364
+ /* Refactored UI Components (Phase 1.2) */
365
+ /* -------------------------------------------------------------------------- */
366
+
367
+ /* Phase 1.2 additions ... */
368
+
369
+ /* Filter Controls */
370
+ .filter-control {
371
+ @apply flex items-center justify-center gap-2 px-3 py-1.5 lg:px-4 lg:py-2
372
+ text-[10px] lg:text-xs font-mono font-medium text-gray-400
373
+ bg-space-800 lg:bg-transparent border border-space-border/50 lg:border-transparent
374
+ rounded lg:rounded-md hover:text-white lg:hover:bg-space-800
375
+ transition-all duration-200 whitespace-nowrap w-full sm:w-auto;
376
+ }
377
+
378
+ .filter-control:hover {
379
+ @apply border-neon-cyan/50 lg:border-neon-cyan/30 lg:shadow-lg lg:shadow-neon-cyan/10;
380
+ }
381
+
382
+ .filter-control-item {
383
+ @apply block w-full px-3 py-1.5 text-left text-[10px] font-mono hover:bg-white/5 transition-colors;
384
+ }
385
+
386
+ /* Action Buttons */
387
+ .btn-action-ghost {
388
+ @apply btn btn-xs btn-ghost text-gray-400 hover:text-white transition-colors;
389
+ }
390
+
391
+ .btn-action-ghost-square {
392
+ @apply btn btn-xs btn-ghost btn-square text-gray-400 hover:text-white transition-colors;
393
+ }
394
+
395
+ .btn-action-primary {
396
+ @apply btn bg-gradient-to-r from-neon-purple to-indigo-600
397
+ border-none text-white shadow-lg shadow-neon-purple/20
398
+ hover:shadow-neon-purple/40 transition-all;
399
+ }
400
+
401
+ .btn-action-success {
402
+ @apply btn btn-xs btn-ghost btn-square text-green-500 hover:bg-green-500/20;
403
+ }
404
+
405
+ .btn-action-danger {
406
+ @apply btn btn-xs btn-ghost btn-square text-red-400 hover:bg-red-500/20;
407
+ }
408
+
409
+ .btn-action-neutral {
410
+ @apply btn btn-xs btn-ghost btn-square text-gray-500 hover:bg-gray-500/20;
411
+ }
412
+
413
+ /* Status Pills/Badges */
414
+ .status-pill {
415
+ @apply px-2 py-1 text-[10px] font-mono font-bold uppercase rounded border;
416
+ }
417
+
418
+ .status-pill-purple {
419
+ @apply status-pill bg-neon-purple/10 text-neon-purple border-neon-purple/30;
420
+ }
421
+
422
+ .status-pill-ultra {
423
+ @apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30;
424
+ }
425
+
426
+ .status-pill-pro {
427
+ @apply status-pill bg-blue-500/10 text-blue-400 border-blue-500/30;
428
+ }
429
+
430
+ .status-pill-free {
431
+ @apply status-pill bg-gray-500/10 text-gray-400 border-gray-500/30;
432
+ }
433
+
434
+ .status-pill-success {
435
+ @apply status-pill bg-neon-green/10 text-neon-green border-neon-green/30;
436
+ }
437
+
438
+ .status-pill-warning {
439
+ @apply status-pill bg-yellow-500/10 text-yellow-400 border-yellow-500/30;
440
+ }
441
+
442
+ .status-pill-error {
443
+ @apply status-pill bg-red-500/10 text-red-400 border-red-500/30;
444
+ }
445
+
446
+ /* Input Components */
447
+ .input-search {
448
+ @apply w-full bg-space-900/50 border border-[rgba(39,39,42,0.5)] text-gray-300
449
+ rounded-lg pl-10 pr-4 py-2
450
+ focus:outline-none focus:bg-space-800 focus:border-neon-purple/50
451
+ hover:border-space-border hover:bg-space-800/80
452
+ transition-all placeholder-gray-600/80;
453
+ }
454
+
455
+ .input-search-sm {
456
+ @apply input-search h-8 text-xs font-normal;
457
+ }
458
+
459
+ /* -------------------------------------------------------------------------- */
460
+ /* Skeleton Loading (Phase 4.1) */
461
+ /* -------------------------------------------------------------------------- */
462
+
463
+ /* Skeleton animation */
464
+ @keyframes skeleton-pulse {
465
+ 0%, 100% {
466
+ opacity: 1;
467
+ }
468
+ 50% {
469
+ opacity: 0.4;
470
+ }
471
+ }
472
+
473
+ /* Base skeleton element */
474
+ .skeleton {
475
+ @apply bg-gradient-to-r from-space-900/60 via-space-800/40 to-space-900/60;
476
+ background-size: 200% 100%;
477
+ animation: skeleton-pulse 1.5s ease-in-out infinite;
478
+ border-radius: 0.375rem;
479
+ }
480
+
481
+ /* Skeleton variants */
482
+ .skeleton-text {
483
+ @apply skeleton h-4 w-full;
484
+ }
485
+
486
+ .skeleton-text-sm {
487
+ @apply skeleton h-3 w-3/4;
488
+ }
489
+
490
+ .skeleton-title {
491
+ @apply skeleton h-6 w-1/2;
492
+ }
493
+
494
+ .skeleton-circle {
495
+ @apply skeleton rounded-full;
496
+ }
497
+
498
+ .skeleton-stat-card {
499
+ @apply skeleton h-32 w-full rounded-xl;
500
+ }
501
+
502
+ .skeleton-chart {
503
+ @apply skeleton h-64 w-full rounded-xl;
504
+ }
505
+
506
+ .skeleton-table-row {
507
+ @apply skeleton h-12 w-full mb-2;
508
+ }
509
+
510
+ /* Fix DaisyUI toggle hover background override */
511
+ .toggle-success:checked,
512
+ .toggle-success[aria-checked="true"] {
513
+ background-color: oklch(var(--su)) !important;
514
+ }
515
+
516
+ /* Desktop Sidebar Collapsed State */
517
+ @media (min-width: 1024px) {
518
+ body .sidebar-collapsed {
519
+ width: 0;
520
+ padding: 0;
521
+ border: none;
522
+ }
523
+ }
public/css/style.css ADDED
Binary file (201 kB). View file
 
public/favicon.svg ADDED
public/index.html ADDED
@@ -0,0 +1,434 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="antigravity" class="dark">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Antigravity Console</title>
8
+ <link rel="icon" type="image/svg+xml" href="favicon.svg">
9
+
10
+ <!-- Libraries -->
11
+ <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
12
+ <!-- Alpine.js must be deferred so stores register their listeners first -->
13
+ <script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
14
+
15
+ <!-- Compiled Tailwind CSS (includes DaisyUI) -->
16
+ <link rel="stylesheet" href="css/style.css">
17
+ </head>
18
+
19
+ <body
20
+ class="bg-space-950 text-gray-300 font-sans antialiased min-h-screen overflow-hidden selection:bg-neon-purple selection:text-white"
21
+ x-cloak x-data="app" x-init="if(window.UILogger) window.UILogger.debug('App initialized')">
22
+
23
+ <!-- Toast Notification -->
24
+ <div class="fixed top-4 right-4 z-[100] flex flex-col gap-2 pointer-events-none">
25
+ <template x-if="$store.global.toast">
26
+ <div x-transition:enter="transition ease-out duration-300"
27
+ x-transition:enter-start="opacity-0 translate-x-8 scale-95"
28
+ x-transition:enter-end="opacity-100 translate-x-0 scale-100"
29
+ x-transition:leave="transition ease-in duration-200"
30
+ x-transition:leave-start="opacity-100 translate-x-0 scale-100"
31
+ x-transition:leave-end="opacity-0 translate-x-4 scale-95"
32
+ class="alert shadow-lg border backdrop-blur-md pointer-events-auto min-w-[300px]" :class="{
33
+ 'alert-info border-neon-cyan/20 bg-space-900/90 text-neon-cyan': $store.global.toast.type === 'info',
34
+ 'alert-success border-neon-green/20 bg-space-900/90 text-neon-green': $store.global.toast.type === 'success',
35
+ 'alert-error border-red-500/20 bg-space-900/90 text-red-400': $store.global.toast.type === 'error'
36
+ }">
37
+ <div class="flex items-center gap-3">
38
+ <!-- Icons based on type -->
39
+ <template x-if="$store.global.toast.type === 'info'">
40
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
41
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
42
+ d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
43
+ </svg>
44
+ </template>
45
+ <template x-if="$store.global.toast.type === 'success'">
46
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
47
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
48
+ d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
49
+ </svg>
50
+ </template>
51
+ <template x-if="$store.global.toast.type === 'error'">
52
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
53
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
54
+ d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
55
+ </svg>
56
+ </template>
57
+ <span x-text="$store.global.toast.message" class="font-mono text-sm"></span>
58
+ </div>
59
+ </div>
60
+ </template>
61
+ </div>
62
+
63
+ <!-- Navbar -->
64
+ <div
65
+ class="h-14 border-b border-space-border flex items-center px-4 lg:px-6 justify-between bg-space-900/50 backdrop-blur-md z-50 relative">
66
+ <div class="flex items-center gap-3">
67
+ <!-- Mobile Menu Button -->
68
+ <button @click="toggleSidebar()" class="text-gray-400 hover:text-white focus:outline-none p-1 transition-colors">
69
+ <svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
70
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
71
+ </svg>
72
+ </button>
73
+
74
+ <div
75
+ class="w-8 h-8 rounded bg-gradient-to-br from-neon-purple to-blue-600 flex items-center justify-center text-white font-bold shadow-[0_0_15px_rgba(168,85,247,0.4)]">
76
+ AG</div>
77
+ <div class="flex flex-col">
78
+ <span class="text-sm font-bold tracking-wide text-white"
79
+ x-text="$store.global.t('systemName')">ANTIGRAVITY</span>
80
+ <div class="flex items-center gap-1.5">
81
+ <span class="text-[10px] text-gray-500 font-mono tracking-wider"
82
+ x-text="$store.global.t('systemDesc')">CLAUDE PROXY SYSTEM</span>
83
+ <span class="text-[10px] text-gray-500 font-mono tracking-wider"
84
+ x-text="'v' + $store.global.version">v1.0.0</span>
85
+ </div>
86
+ </div>
87
+ </div>
88
+
89
+ <div class="flex items-center gap-4">
90
+ <!-- Connection Pill -->
91
+ <div class="flex items-center gap-2 px-3 py-1 rounded-full text-xs font-mono border transition-all duration-300"
92
+ :class="connectionStatus === 'connected'
93
+ ? 'bg-neon-green/10 border-neon-green/20 text-neon-green'
94
+ : (connectionStatus === 'connecting' ? 'bg-yellow-500/10 border-yellow-500/20 text-yellow-500' : 'bg-red-500/10 border-red-500/20 text-red-500')">
95
+ <div class="w-1.5 h-1.5 rounded-full"
96
+ :class="connectionStatus === 'connected' ? 'bg-neon-green shadow-[0_0_8px_rgba(34,197,94,0.6)]' : (connectionStatus === 'connecting' ? 'bg-yellow-500 animate-pulse' : 'bg-red-500')">
97
+ </div>
98
+ <span
99
+ x-text="connectionStatus === 'connected' ? $store.global.t('online') : (connectionStatus === 'disconnected' ? $store.global.t('offline') : $store.global.t('connecting'))"></span>
100
+ </div>
101
+
102
+ <div class="h-4 w-px bg-space-border"></div>
103
+
104
+ <!-- Refresh Button -->
105
+ <button type="button" class="btn btn-ghost btn-xs btn-square text-gray-400 hover:text-white hover:bg-white/5"
106
+ @click="fetchData" :disabled="loading" :title="$store.global.t('refreshData')" aria-label="Refresh data">
107
+ <svg class="w-4 h-4" :class="{'animate-spin': loading}" fill="none" stroke="currentColor"
108
+ viewBox="0 0 24 24">
109
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
110
+ d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
111
+ </svg>
112
+ </button>
113
+ </div>
114
+ </div>
115
+
116
+ <!-- Layout -->
117
+ <div class="flex h-[calc(100vh-56px)] relative">
118
+
119
+ <!-- Mobile Sidebar Overlay -->
120
+ <div x-show="sidebarOpen"
121
+ x-transition:enter="transition-opacity ease-linear duration-300"
122
+ x-transition:enter-start="opacity-0"
123
+ x-transition:enter-end="opacity-100"
124
+ x-transition:leave="transition-opacity ease-linear duration-300"
125
+ x-transition:leave-start="opacity-100"
126
+ x-transition:leave-end="opacity-0"
127
+ @click="sidebarOpen = false"
128
+ class="fixed inset-0 bg-black/50 z-40 lg:hidden"
129
+ style="display: none;"></div>
130
+
131
+ <!-- Sidebar -->
132
+ <div class="fixed top-14 bottom-0 left-0 z-40 bg-space-900 border-r border-space-border transition-all duration-300 shadow-2xl overflow-hidden lg:static lg:h-auto lg:shadow-none lg:flex-shrink-0"
133
+ :class="{
134
+ 'translate-x-0': sidebarOpen,
135
+ '-translate-x-full': !sidebarOpen,
136
+ 'w-64': sidebarOpen,
137
+ 'lg:translate-x-0': sidebarOpen,
138
+ 'lg:w-64': sidebarOpen,
139
+ 'sidebar-collapsed': !sidebarOpen
140
+ }">
141
+
142
+ <!-- Inner Sidebar Content (Fixed Width to prevent squashing) -->
143
+ <div class="w-64 flex flex-col h-full pt-6 pb-4 flex-shrink-0">
144
+ <!-- Mobile Menu Header -->
145
+ <div class="flex items-center justify-between px-4 mb-6 lg:hidden">
146
+ <span class="text-sm font-bold text-white" x-text="$store.global.t('menu')">Menu</span>
147
+ <button @click="sidebarOpen = false" class="text-gray-400 hover:text-white">
148
+ <svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
149
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
150
+ </svg>
151
+ </button>
152
+ </div>
153
+
154
+ <!-- Desktop Header (Main) -->
155
+ <div class="px-4 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest hidden lg:block"
156
+ x-text="$store.global.t('main')">Main</div>
157
+
158
+ <nav class="flex flex-col gap-1">
159
+ <button
160
+ class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
161
+ :class="{'active': $store.global.activeTab === 'dashboard'}"
162
+ @click="$store.global.activeTab = 'dashboard'">
163
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
164
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
165
+ d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z" />
166
+ </svg>
167
+ <span x-text="$store.global.t('dashboard')">Dashboard</span>
168
+ </button>
169
+ <button
170
+ class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
171
+ :class="{'active': $store.global.activeTab === 'models'}"
172
+ @click="$store.global.activeTab = 'models'">
173
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
174
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
175
+ d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
176
+ </svg>
177
+ <span x-text="$store.global.t('models')">Models</span>
178
+ </button>
179
+ <button
180
+ class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
181
+ :class="{'active': $store.global.activeTab === 'accounts'}"
182
+ @click="$store.global.activeTab = 'accounts'">
183
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
184
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
185
+ d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
186
+ </svg>
187
+ <span x-text="$store.global.t('accounts')">Accounts</span>
188
+ </button>
189
+ </nav>
190
+
191
+ <div class="px-4 mt-8 mb-2 text-xs font-bold text-gray-600 uppercase tracking-widest"
192
+ x-text="$store.global.t('system')">System</div>
193
+ <nav class="flex flex-col gap-1">
194
+ <button
195
+ class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
196
+ :class="{'active': $store.global.activeTab === 'logs'}" @click="$store.global.activeTab = 'logs'">
197
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
198
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
199
+ d="M8 9l3 3-3 3m5 0h3M5 20h14a2 2 0 002-2V6a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
200
+ </svg>
201
+ <span x-text="$store.global.t('logs')">Logs</span>
202
+ </button>
203
+ <button
204
+ class="nav-item flex items-center gap-3 px-6 py-3 text-sm font-medium text-gray-400 hover:text-white hover:bg-white/5"
205
+ :class="{'active': $store.global.activeTab === 'settings'}"
206
+ @click="$store.global.activeTab = 'settings'">
207
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
208
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
209
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
210
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
211
+ d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
212
+ </svg>
213
+ <span x-text="$store.global.t('settings')">Settings</span>
214
+ </button>
215
+ </nav>
216
+
217
+ <!-- Footer Info -->
218
+ <div class="mt-auto px-6 text-xs text-gray-400 font-mono">
219
+ <div class="flex justify-center">
220
+ <a href="https://github.com/badri-s2001/antigravity-claude-proxy" target="_blank" rel="noopener noreferrer"
221
+ class="flex items-center gap-1.5 hover:text-neon-purple transition-colors">
222
+ <svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
223
+ <path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>
224
+ </svg>
225
+ <span x-text="$store.global.t('github')">GitHub</span>
226
+ </a>
227
+ </div>
228
+ </div>
229
+ </div>
230
+ </div>
231
+
232
+ <!-- Main Content -->
233
+ <div class="flex-1 overflow-auto bg-space-950 relative custom-scrollbar">
234
+
235
+ <!-- Views Container -->
236
+ <!-- Dashboard -->
237
+ <div x-show="$store.global.activeTab === 'dashboard'" x-load-view="'dashboard'"
238
+ x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
239
+ class="w-full"></div>
240
+
241
+ <!-- Models -->
242
+ <div x-show="$store.global.activeTab === 'models'" x-load-view="'models'"
243
+ x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
244
+ class="w-full"></div>
245
+
246
+ <!-- Logs -->
247
+ <div x-show="$store.global.activeTab === 'logs'" x-load-view="'logs'" x-transition:enter="fade-enter-active"
248
+ x-transition:enter-start="fade-enter-from" class="w-full h-full"></div>
249
+
250
+ <!-- Accounts -->
251
+ <div x-show="$store.global.activeTab === 'accounts'" x-load-view="'accounts'"
252
+ x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
253
+ class="w-full"></div>
254
+
255
+ <!-- Settings -->
256
+ <div x-show="$store.global.activeTab === 'settings'" x-load-view="'settings'"
257
+ x-transition:enter="fade-enter-active" x-transition:enter-start="fade-enter-from"
258
+ class="w-full"></div>
259
+ </div>
260
+ </div>
261
+
262
+ <!-- Add Account Modal -->
263
+ <dialog id="add_account_modal" class="modal backdrop-blur-sm" x-data="addAccountModal">
264
+ <div class="modal-box max-w-lg w-full bg-space-900 border border-space-border text-gray-300 shadow-[0_0_50px_rgba(0,0,0,0.5)] p-6">
265
+ <h3 class="font-bold text-lg text-white mb-4" x-text="$store.global.t('addAccount')">Add New Account</h3>
266
+
267
+ <div class="flex flex-col gap-4">
268
+ <p class="text-sm text-gray-400 leading-relaxed" x-text="$store.global.t('connectGoogleDesc')">Connect a Google
269
+ Workspace account to increase your API quota limit.
270
+ The account will be used to proxy Claude requests via Antigravity.</p>
271
+
272
+ <button class="btn btn-primary flex items-center justify-center gap-3 h-11" @click="addAccountWeb">
273
+ <svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
274
+ <path
275
+ d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z">
276
+ </path>
277
+ <path
278
+ d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z">
279
+ </path>
280
+ <path
281
+ d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z">
282
+ </path>
283
+ <path
284
+ d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z">
285
+ </path>
286
+ </svg>
287
+ <span x-text="$store.global.t('connectGoogle')">Connect Google Account</span>
288
+ </button>
289
+
290
+ <div class="text-center mt-2">
291
+ <p class="text-xs text-gray-500 mb-2" x-text="$store.global.t('or')">OR</p>
292
+
293
+ <!-- Manual Mode (collapsible) -->
294
+ <details class="group mb-2" @toggle="initManualAuth($event)">
295
+ <summary class="text-xs text-gray-400 hover:text-neon-cyan cursor-pointer transition-colors inline-flex items-center gap-1">
296
+ <svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
297
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
298
+ </svg>
299
+ <span x-text="$store.global.t('manualMode')">Manual Mode</span>
300
+ </summary>
301
+ <div class="mt-3 p-3 bg-black/50 rounded border border-space-border/30 text-xs">
302
+ <template x-if="authUrl">
303
+ <div>
304
+ <div class="flex items-center gap-2 mb-2">
305
+ <input type="text" readonly :value="authUrl"
306
+ class="input input-xs input-bordered flex-1 bg-space-800 text-gray-300 font-mono">
307
+ <button class="btn btn-xs btn-ghost text-neon-cyan" @click="copyLink" :title="$store.global.t('linkCopied')">
308
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
309
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
310
+ </svg>
311
+ </button>
312
+ </div>
313
+ <div class="flex items-center gap-2">
314
+ <input type="text" x-model="callbackInput"
315
+ :placeholder="$store.global.t('pasteCallbackPlaceholder')"
316
+ class="input input-xs input-bordered flex-1 bg-space-800 text-gray-300 font-mono">
317
+ <button class="btn btn-xs btn-success"
318
+ @click="completeManualAuth"
319
+ :disabled="!callbackInput || submitting"
320
+ :class="{ 'loading': submitting }">
321
+ <span x-text="$store.global.t('completeAuth')">OK</span>
322
+ </button>
323
+ </div>
324
+ </div>
325
+ </template>
326
+ <template x-if="!authUrl">
327
+ <div class="text-gray-500">Loading...</div>
328
+ </template>
329
+ </div>
330
+ </details>
331
+
332
+ <!-- CLI Command -->
333
+ <details class="group">
334
+ <summary class="text-xs text-gray-400 hover:text-neon-cyan cursor-pointer transition-colors inline-flex items-center gap-1">
335
+ <svg class="w-3 h-3 transition-transform group-open:rotate-90" fill="none" stroke="currentColor" viewBox="0 0 24 24">
336
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
337
+ </svg>
338
+ <span x-text="$store.global.t('useCliCommand')">Use CLI Command</span>
339
+ </summary>
340
+ <div class="mt-3 p-3 bg-black/50 rounded border border-space-border/30 font-mono text-xs text-gray-300">
341
+ <div class="flex items-center gap-2">
342
+ <span class="text-gray-600">$</span>
343
+ <code>npm run accounts:add</code>
344
+ </div>
345
+ </div>
346
+ </details>
347
+ </div>
348
+ </div>
349
+
350
+ <div class="modal-action mt-6">
351
+ <form method="dialog">
352
+ <button type="submit" class="btn btn-ghost hover:bg-white/10" @click="resetState()" x-text="$store.global.t('close')">Close</button>
353
+ </form>
354
+ </div>
355
+ </div>
356
+ <form method="dialog" class="modal-backdrop">
357
+ <button type="button" @click="resetState()" x-text="$store.global.t('close')">close</button>
358
+ </form>
359
+ </dialog>
360
+
361
+ <!-- OAuth Progress Modal -->
362
+ <dialog id="oauth_progress_modal" class="modal" :class="{ 'modal-open': $store.global.oauthProgress.active }">
363
+ <div class="modal-box bg-space-900 border border-neon-purple/50">
364
+ <h3 class="font-bold text-lg text-white flex items-center gap-2">
365
+ <svg class="w-6 h-6 text-neon-purple animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24">
366
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
367
+ </svg>
368
+ <span x-text="$store.global.t('oauthWaiting')">Waiting for OAuth...</span>
369
+ </h3>
370
+ <p class="py-4 text-gray-400 text-sm" x-text="$store.global.t('oauthWaitingDesc')">
371
+ Please complete authentication in the popup window.
372
+ </p>
373
+
374
+ <!-- Progress Bar -->
375
+ <div class="w-full bg-space-800 rounded-full h-2 mb-4 overflow-hidden">
376
+ <div class="bg-neon-purple h-2 rounded-full transition-all duration-500"
377
+ :style="`width: ${($store.global.oauthProgress.current / $store.global.oauthProgress.max) * 100}%`">
378
+ </div>
379
+ </div>
380
+
381
+ <!-- Progress Text -->
382
+ <div class="flex justify-between text-xs text-gray-600 mb-4">
383
+ <span x-text="`${$store.global.oauthProgress.current} / ${$store.global.oauthProgress.max}s`"></span>
384
+ <span x-text="`${Math.round(($store.global.oauthProgress.current / $store.global.oauthProgress.max) * 100)}%`"></span>
385
+ </div>
386
+
387
+ <div class="modal-action">
388
+ <button class="btn btn-sm btn-ghost text-gray-400"
389
+ @click="$store.global.oauthProgress.cancel && $store.global.oauthProgress.cancel()"
390
+ x-text="$store.global.t('cancelOAuth')">
391
+ Cancel
392
+ </button>
393
+ </div>
394
+ </div>
395
+ </dialog>
396
+
397
+ <!-- Scripts - Loading Order Matters! -->
398
+ <!-- 1. Config & Utils (global helpers) -->
399
+ <script src="js/config/constants.js"></script>
400
+ <script src="js/utils/ui-logger.js"></script><!-- Issue #183: Conditional logging utility -->
401
+ <script src="js/utils.js"></script>
402
+ <script src="js/utils/error-handler.js"></script>
403
+ <script src="js/utils/account-actions.js"></script>
404
+ <script src="js/utils/validators.js"></script>
405
+ <script src="js/utils/model-config.js"></script>
406
+ <script src="js/utils/redact.js"></script>
407
+ <!-- Translation files (must load before store.js) -->
408
+ <script src="js/translations/en.js"></script>
409
+ <script src="js/translations/zh.js"></script>
410
+ <script src="js/translations/tr.js"></script>
411
+ <script src="js/translations/id.js"></script>
412
+ <script src="js/translations/pt.js"></script>
413
+ <!-- 2. Alpine Stores (register alpine:init listeners) -->
414
+ <script src="js/store.js"></script>
415
+ <script src="js/data-store.js"></script>
416
+ <script src="js/settings-store.js"></script>
417
+ <!-- 3. Components (register to window.Components) -->
418
+ <!-- Dashboard modules (load before main dashboard) -->
419
+ <script src="js/components/dashboard/stats.js"></script>
420
+ <script src="js/components/dashboard/charts.js"></script>
421
+ <script src="js/components/dashboard/filters.js"></script>
422
+ <script src="js/components/dashboard.js"></script>
423
+ <script src="js/components/models.js"></script>
424
+ <script src="js/components/account-manager.js"></script>
425
+ <script src="js/components/claude-config.js"></script>
426
+ <script src="js/components/model-dropdown.js"></script>
427
+ <script src="js/components/logs-viewer.js"></script>
428
+ <script src="js/components/server-config.js"></script>
429
+ <script src="js/components/add-account-modal.js"></script>
430
+ <!-- 4. App (registers Alpine components from window.Components) -->
431
+ <script src="app.js"></script>
432
+ </body>
433
+
434
+ </html>
public/js/components/account-manager.js ADDED
@@ -0,0 +1,502 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Account Manager Component
3
+ * Registers itself to window.Components for Alpine.js to consume
4
+ */
5
+ window.Components = window.Components || {};
6
+
7
+ window.Components.accountManager = () => ({
8
+ searchQuery: '',
9
+ deleteTarget: '',
10
+ refreshing: false,
11
+ toggling: false,
12
+ deleting: false,
13
+ reloading: false,
14
+ selectedAccountEmail: '',
15
+ selectedAccountLimits: {},
16
+
17
+ // Health Inspector (Developer Mode)
18
+ healthData: {},
19
+ healthLoading: false,
20
+
21
+ init() {
22
+ if (Alpine.store('data').devMode && Alpine.store('settings').healthInspectorOpen) {
23
+ this.fetchHealthData();
24
+ }
25
+ },
26
+
27
+ get filteredAccounts() {
28
+ const accounts = Alpine.store('data').accounts || [];
29
+ if (!this.searchQuery || this.searchQuery.trim() === '') {
30
+ return accounts;
31
+ }
32
+
33
+ const query = this.searchQuery.toLowerCase().trim();
34
+ return accounts.filter(acc => {
35
+ return acc.email.toLowerCase().includes(query) ||
36
+ (acc.projectId && acc.projectId.toLowerCase().includes(query)) ||
37
+ (acc.source && acc.source.toLowerCase().includes(query));
38
+ });
39
+ },
40
+
41
+ formatEmail(email) {
42
+ if (!email || email.length <= 40) return email;
43
+
44
+ const [user, domain] = email.split('@');
45
+ if (!domain) return email;
46
+
47
+ // Preserve domain integrity, truncate username if needed
48
+ if (user.length > 20) {
49
+ return `${user.substring(0, 10)}...${user.slice(-5)}@${domain}`;
50
+ }
51
+ return email;
52
+ },
53
+
54
+ async refreshAccount(email) {
55
+ return await window.ErrorHandler.withLoading(async () => {
56
+ const store = Alpine.store('global');
57
+ store.showToast(store.t('refreshingAccount', { email: Redact.email(email) }), 'info');
58
+
59
+ const { response, newPassword } = await window.utils.request(
60
+ `/api/accounts/${encodeURIComponent(email)}/refresh`,
61
+ { method: 'POST' },
62
+ store.webuiPassword
63
+ );
64
+ if (newPassword) store.webuiPassword = newPassword;
65
+
66
+ const data = await response.json();
67
+ if (data.status === 'ok') {
68
+ store.showToast(store.t('refreshedAccount', { email: Redact.email(email) }), 'success');
69
+ Alpine.store('data').fetchData();
70
+ } else {
71
+ throw new Error(data.error || store.t('refreshFailed'));
72
+ }
73
+ }, this, 'refreshing', { errorMessage: 'Failed to refresh account' });
74
+ },
75
+
76
+ async toggleAccount(email, enabled) {
77
+ const store = Alpine.store('global');
78
+ const password = store.webuiPassword;
79
+
80
+ // Optimistic update: immediately update UI
81
+ const dataStore = Alpine.store('data');
82
+ const account = dataStore.accounts.find(a => a.email === email);
83
+ if (account) {
84
+ account.enabled = enabled;
85
+ }
86
+
87
+ try {
88
+ const { response, newPassword } = await window.utils.request(`/api/accounts/${encodeURIComponent(email)}/toggle`, {
89
+ method: 'POST',
90
+ headers: { 'Content-Type': 'application/json' },
91
+ body: JSON.stringify({ enabled })
92
+ }, password);
93
+ if (newPassword) store.webuiPassword = newPassword;
94
+
95
+ const data = await response.json();
96
+ if (data.status === 'ok') {
97
+ const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
98
+ store.showToast(store.t('accountToggled', { email: Redact.email(email), status }), 'success');
99
+ // Refresh to confirm server state
100
+ await dataStore.fetchData();
101
+ } else {
102
+ store.showToast(data.error || store.t('toggleFailed'), 'error');
103
+ // Rollback optimistic update on error
104
+ if (account) {
105
+ account.enabled = !enabled;
106
+ }
107
+ await dataStore.fetchData();
108
+ }
109
+ } catch (e) {
110
+ store.showToast(store.t('toggleFailed') + ': ' + e.message, 'error');
111
+ // Rollback optimistic update on error
112
+ if (account) {
113
+ account.enabled = !enabled;
114
+ }
115
+ await dataStore.fetchData();
116
+ }
117
+ },
118
+
119
+ async fixAccount(email) {
120
+ const store = Alpine.store('global');
121
+ const dataStore = Alpine.store('data');
122
+ // If the account has a verification URL (403 VALIDATION_REQUIRED), open it directly
123
+ const account = (dataStore.accounts || []).find(a => a.email === email);
124
+ if (account?.verifyUrl) {
125
+ window.open(account.verifyUrl, '_blank');
126
+ store.showToast(store.t('verifyThenRefresh') || 'After completing verification, click the ↻ Refresh button to re-enable this account', 'info', 10000);
127
+ return;
128
+ }
129
+ // Otherwise fall back to OAuth re-auth
130
+ store.showToast(store.t('reauthenticating', { email: Redact.email(email) }), 'info');
131
+ const password = store.webuiPassword;
132
+ try {
133
+ const urlPath = `/api/auth/url?email=${encodeURIComponent(email)}`;
134
+ const { response, newPassword } = await window.utils.request(urlPath, {}, password);
135
+ if (newPassword) store.webuiPassword = newPassword;
136
+
137
+ const data = await response.json();
138
+ if (data.status === 'ok') {
139
+ window.open(data.url, 'google_oauth', 'width=600,height=700,scrollbars=yes');
140
+ } else {
141
+ store.showToast(data.error || store.t('authUrlFailed'), 'error');
142
+ }
143
+ } catch (e) {
144
+ store.showToast(store.t('authUrlFailed') + ': ' + e.message, 'error');
145
+ }
146
+ },
147
+
148
+ confirmDeleteAccount(email) {
149
+ this.deleteTarget = email;
150
+ document.getElementById('delete_account_modal').showModal();
151
+ },
152
+
153
+ async executeDelete() {
154
+ const email = this.deleteTarget;
155
+ return await window.ErrorHandler.withLoading(async () => {
156
+ const store = Alpine.store('global');
157
+
158
+ const { response, newPassword } = await window.utils.request(
159
+ `/api/accounts/${encodeURIComponent(email)}`,
160
+ { method: 'DELETE' },
161
+ store.webuiPassword
162
+ );
163
+ if (newPassword) store.webuiPassword = newPassword;
164
+
165
+ const data = await response.json();
166
+ if (data.status === 'ok') {
167
+ store.showToast(store.t('deletedAccount', { email: Redact.email(email) }), 'success');
168
+ Alpine.store('data').fetchData();
169
+ document.getElementById('delete_account_modal').close();
170
+ this.deleteTarget = '';
171
+ } else {
172
+ throw new Error(data.error || store.t('deleteFailed'));
173
+ }
174
+ }, this, 'deleting', { errorMessage: 'Failed to delete account' });
175
+ },
176
+
177
+ async reloadAccounts() {
178
+ return await window.ErrorHandler.withLoading(async () => {
179
+ const store = Alpine.store('global');
180
+
181
+ const { response, newPassword } = await window.utils.request(
182
+ '/api/accounts/reload',
183
+ { method: 'POST' },
184
+ store.webuiPassword
185
+ );
186
+ if (newPassword) store.webuiPassword = newPassword;
187
+
188
+ const data = await response.json();
189
+ if (data.status === 'ok') {
190
+ store.showToast(store.t('accountsReloaded'), 'success');
191
+ Alpine.store('data').fetchData();
192
+ } else {
193
+ throw new Error(data.error || store.t('reloadFailed'));
194
+ }
195
+ }, this, 'reloading', { errorMessage: 'Failed to reload accounts' });
196
+ },
197
+
198
+ openQuotaModal(account) {
199
+ this.selectedAccountEmail = account.email;
200
+ this.selectedAccountLimits = account.limits || {};
201
+ document.getElementById('quota_modal').showModal();
202
+ },
203
+
204
+ // Threshold settings
205
+ thresholdDialog: {
206
+ email: '',
207
+ quotaThreshold: null, // null means use global
208
+ modelQuotaThresholds: {},
209
+ saving: false,
210
+ addingModel: false,
211
+ newModelId: '',
212
+ newModelThreshold: 10
213
+ },
214
+
215
+ openThresholdModal(account) {
216
+ this.thresholdDialog = {
217
+ email: account.email,
218
+ // Convert from fraction (0-1) to percentage (0-99) for display
219
+ quotaThreshold: account.quotaThreshold !== undefined ? Math.round(account.quotaThreshold * 100) : null,
220
+ modelQuotaThresholds: Object.fromEntries(
221
+ Object.entries(account.modelQuotaThresholds || {}).map(([k, v]) => [k, Math.round(v * 100)])
222
+ ),
223
+ saving: false,
224
+ addingModel: false,
225
+ newModelId: '',
226
+ newModelThreshold: 10
227
+ };
228
+ document.getElementById('threshold_modal').showModal();
229
+ },
230
+
231
+ async saveAccountThreshold() {
232
+ const store = Alpine.store('global');
233
+ this.thresholdDialog.saving = true;
234
+
235
+ try {
236
+ // Convert percentage back to fraction
237
+ const quotaThreshold = this.thresholdDialog.quotaThreshold !== null && this.thresholdDialog.quotaThreshold !== ''
238
+ ? parseFloat(this.thresholdDialog.quotaThreshold) / 100
239
+ : null;
240
+
241
+ // Convert model thresholds from percentage to fraction
242
+ const modelQuotaThresholds = {};
243
+ for (const [modelId, pct] of Object.entries(this.thresholdDialog.modelQuotaThresholds)) {
244
+ modelQuotaThresholds[modelId] = parseFloat(pct) / 100;
245
+ }
246
+
247
+ const { response, newPassword } = await window.utils.request(
248
+ `/api/accounts/${encodeURIComponent(this.thresholdDialog.email)}`,
249
+ {
250
+ method: 'PATCH',
251
+ headers: { 'Content-Type': 'application/json' },
252
+ body: JSON.stringify({ quotaThreshold, modelQuotaThresholds })
253
+ },
254
+ store.webuiPassword
255
+ );
256
+ if (newPassword) store.webuiPassword = newPassword;
257
+
258
+ const data = await response.json();
259
+ if (data.status === 'ok') {
260
+ store.showToast('Settings saved', 'success');
261
+ Alpine.store('data').fetchData();
262
+ document.getElementById('threshold_modal').close();
263
+ } else {
264
+ throw new Error(data.error || 'Failed to save settings');
265
+ }
266
+ } catch (e) {
267
+ store.showToast('Failed to save settings: ' + e.message, 'error');
268
+ } finally {
269
+ this.thresholdDialog.saving = false;
270
+ }
271
+ },
272
+
273
+ clearAccountThreshold() {
274
+ this.thresholdDialog.quotaThreshold = null;
275
+ },
276
+
277
+ // Per-model threshold methods
278
+ addModelThreshold() {
279
+ this.thresholdDialog.addingModel = true;
280
+ this.thresholdDialog.newModelId = '';
281
+ this.thresholdDialog.newModelThreshold = 10;
282
+ },
283
+
284
+ updateModelThreshold(modelId, value) {
285
+ const numValue = parseInt(value);
286
+ if (!isNaN(numValue) && numValue >= 0 && numValue <= 99) {
287
+ this.thresholdDialog.modelQuotaThresholds[modelId] = numValue;
288
+ }
289
+ },
290
+
291
+ removeModelThreshold(modelId) {
292
+ delete this.thresholdDialog.modelQuotaThresholds[modelId];
293
+ },
294
+
295
+ confirmAddModelThreshold() {
296
+ const modelId = this.thresholdDialog.newModelId;
297
+ const threshold = parseInt(this.thresholdDialog.newModelThreshold) || 10;
298
+
299
+ if (modelId && threshold >= 0 && threshold <= 99) {
300
+ this.thresholdDialog.modelQuotaThresholds[modelId] = threshold;
301
+ this.thresholdDialog.addingModel = false;
302
+ this.thresholdDialog.newModelId = '';
303
+ this.thresholdDialog.newModelThreshold = 10;
304
+ }
305
+ },
306
+
307
+ getAvailableModelsForThreshold() {
308
+ // Get models from data store, exclude already configured ones
309
+ const allModels = Alpine.store('data').models || [];
310
+ const configured = Object.keys(this.thresholdDialog.modelQuotaThresholds);
311
+ return allModels.filter(m => !configured.includes(m));
312
+ },
313
+
314
+ getEffectiveThreshold(account) {
315
+ // Return display string for effective threshold
316
+ if (account.quotaThreshold !== undefined) {
317
+ return Math.round(account.quotaThreshold * 100) + '%';
318
+ }
319
+ // If no per-account threshold, show global value
320
+ const globalThreshold = Alpine.store('data').globalQuotaThreshold;
321
+ if (globalThreshold > 0) {
322
+ return Math.round(globalThreshold * 100) + '% (global)';
323
+ }
324
+ return 'Global';
325
+ },
326
+
327
+ /**
328
+ * Get main model quota for display
329
+ * Prioritizes flagship models (Opus > Sonnet > Flash)
330
+ * @param {Object} account - Account object with limits
331
+ * @returns {Object} { percent: number|null, model: string }
332
+ */
333
+ getMainModelQuota(account) {
334
+ const limits = account.limits || {};
335
+
336
+ const getQuotaVal = (id) => {
337
+ const l = limits[id];
338
+ if (!l) return -1;
339
+ if (l.remainingFraction !== null) return l.remainingFraction;
340
+ if (l.resetTime) return 0; // Rate limited
341
+ return -1; // Unknown
342
+ };
343
+
344
+ const validIds = Object.keys(limits).filter(id => getQuotaVal(id) >= 0);
345
+
346
+ if (validIds.length === 0) return { percent: null, model: '-' };
347
+
348
+ const DEAD_THRESHOLD = 0.01;
349
+
350
+ const MODEL_TIERS = [
351
+ { pattern: /\bopus\b/, aliveScore: 100, deadScore: 60 },
352
+ { pattern: /\bsonnet\b/, aliveScore: 90, deadScore: 55 },
353
+ // Gemini 3 Pro / Ultra
354
+ { pattern: /\bgemini-3\b/, extraCheck: (l) => /\bpro\b/.test(l) || /\bultra\b/.test(l), aliveScore: 80, deadScore: 50 },
355
+ { pattern: /\bpro\b/, aliveScore: 75, deadScore: 45 },
356
+ // Mid/Low Tier
357
+ { pattern: /\bhaiku\b/, aliveScore: 30, deadScore: 15 },
358
+ { pattern: /\bflash\b/, aliveScore: 20, deadScore: 10 }
359
+ ];
360
+
361
+ const getPriority = (id) => {
362
+ const lower = id.toLowerCase();
363
+ const val = getQuotaVal(id);
364
+ const isAlive = val > DEAD_THRESHOLD;
365
+
366
+ for (const tier of MODEL_TIERS) {
367
+ if (tier.pattern.test(lower)) {
368
+ if (tier.extraCheck && !tier.extraCheck(lower)) continue;
369
+ return isAlive ? tier.aliveScore : tier.deadScore;
370
+ }
371
+ }
372
+
373
+ return isAlive ? 5 : 0;
374
+ };
375
+
376
+ // Sort by priority desc
377
+ validIds.sort((a, b) => getPriority(b) - getPriority(a));
378
+
379
+ const bestModel = validIds[0];
380
+ const val = getQuotaVal(bestModel);
381
+
382
+ return {
383
+ percent: Math.round(val * 100),
384
+ model: bestModel
385
+ };
386
+ },
387
+
388
+ /**
389
+ * Fetch strategy health data for the inspector panel
390
+ */
391
+ async fetchHealthData() {
392
+ this.healthLoading = true;
393
+ try {
394
+ const store = Alpine.store('global');
395
+ const { response, newPassword } = await window.utils.request(
396
+ '/api/strategy/health',
397
+ {},
398
+ store.webuiPassword
399
+ );
400
+ if (newPassword) store.webuiPassword = newPassword;
401
+
402
+ const data = await response.json();
403
+ if (data.status === 'ok') {
404
+ this.healthData = data;
405
+ } else {
406
+ this.healthData = {};
407
+ if (response.status === 403) {
408
+ store.showToast(data.error || 'Developer mode is not enabled', 'warning');
409
+ }
410
+ }
411
+ } catch (e) {
412
+ console.error('Failed to fetch health data:', e);
413
+ } finally {
414
+ this.healthLoading = false;
415
+ }
416
+ },
417
+
418
+ /**
419
+ * Export accounts to JSON file
420
+ */
421
+ async exportAccounts() {
422
+ const store = Alpine.store('global');
423
+ try {
424
+ const { response, newPassword } = await window.utils.request(
425
+ '/api/accounts/export',
426
+ {},
427
+ store.webuiPassword
428
+ );
429
+ if (newPassword) store.webuiPassword = newPassword;
430
+
431
+ const data = await response.json();
432
+ // API returns plain array directly
433
+ if (Array.isArray(data)) {
434
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
435
+ const url = URL.createObjectURL(blob);
436
+ const a = document.createElement('a');
437
+ a.href = url;
438
+ a.download = `antigravity-accounts-${new Date().toISOString().split('T')[0]}.json`;
439
+ document.body.appendChild(a);
440
+ a.click();
441
+ document.body.removeChild(a);
442
+ URL.revokeObjectURL(url);
443
+
444
+ store.showToast(store.t('exportSuccess', { count: data.length }), 'success');
445
+ } else if (data.error) {
446
+ throw new Error(data.error);
447
+ }
448
+ } catch (e) {
449
+ store.showToast(store.t('exportFailed') + ': ' + e.message, 'error');
450
+ }
451
+ },
452
+
453
+ /**
454
+ * Import accounts from JSON file
455
+ * @param {Event} event - file input change event
456
+ */
457
+ async importAccounts(event) {
458
+ const store = Alpine.store('global');
459
+ const file = event.target.files?.[0];
460
+ if (!file) return;
461
+
462
+ try {
463
+ const text = await file.text();
464
+ const importData = JSON.parse(text);
465
+
466
+ // Support both plain array and wrapped format
467
+ const accounts = Array.isArray(importData) ? importData : (importData.accounts || []);
468
+ if (!Array.isArray(accounts) || accounts.length === 0) {
469
+ throw new Error('Invalid file format: expected accounts array');
470
+ }
471
+
472
+ const { response, newPassword } = await window.utils.request(
473
+ '/api/accounts/import',
474
+ {
475
+ method: 'POST',
476
+ headers: { 'Content-Type': 'application/json' },
477
+ body: JSON.stringify(accounts)
478
+ },
479
+ store.webuiPassword
480
+ );
481
+ if (newPassword) store.webuiPassword = newPassword;
482
+
483
+ const data = await response.json();
484
+ if (data.status === 'ok') {
485
+ const { added, updated, failed } = data.results;
486
+ let msg = store.t('importSuccess') + ` ${added.length} added, ${updated.length} updated`;
487
+ if (failed.length > 0) {
488
+ msg += `, ${failed.length} failed`;
489
+ }
490
+ store.showToast(msg, failed.length > 0 ? 'info' : 'success');
491
+ Alpine.store('data').fetchData();
492
+ } else {
493
+ throw new Error(data.error || 'Import failed');
494
+ }
495
+ } catch (e) {
496
+ store.showToast(store.t('importFailed') + ': ' + e.message, 'error');
497
+ } finally {
498
+ // Reset file input
499
+ event.target.value = '';
500
+ }
501
+ }
502
+ });
public/js/components/add-account-modal.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Add Account Modal Component
3
+ * Registers itself to window.Components for Alpine.js to consume
4
+ */
5
+ window.Components = window.Components || {};
6
+
7
+ window.Components.addAccountModal = () => ({
8
+ manualMode: false,
9
+ authUrl: '',
10
+ authState: '',
11
+ callbackInput: '',
12
+ submitting: false,
13
+
14
+ /**
15
+ * Reset all state to initial values
16
+ */
17
+ resetState() {
18
+ this.manualMode = false;
19
+ this.authUrl = '';
20
+ this.authState = '';
21
+ this.callbackInput = '';
22
+ this.submitting = false;
23
+ // Close any open details elements
24
+ const details = document.querySelectorAll('#add_account_modal details[open]');
25
+ details.forEach(d => d.removeAttribute('open'));
26
+ },
27
+
28
+ async copyLink() {
29
+ if (!this.authUrl) return;
30
+ await navigator.clipboard.writeText(this.authUrl);
31
+ Alpine.store('global').showToast(Alpine.store('global').t('linkCopied'), 'success');
32
+ },
33
+
34
+ async initManualAuth(event) {
35
+ if (event.target.open && !this.authUrl) {
36
+ try {
37
+ const password = Alpine.store('global').webuiPassword;
38
+ const {
39
+ response,
40
+ newPassword
41
+ } = await window.utils.request('/api/auth/url', {}, password);
42
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
43
+ const data = await response.json();
44
+ if (data.status === 'ok') {
45
+ this.authUrl = data.url;
46
+ this.authState = data.state;
47
+ }
48
+ } catch (e) {
49
+ Alpine.store('global').showToast(e.message, 'error');
50
+ }
51
+ }
52
+ },
53
+
54
+ async completeManualAuth() {
55
+ if (!this.callbackInput || !this.authState) return;
56
+ this.submitting = true;
57
+ try {
58
+ const store = Alpine.store('global');
59
+ const {
60
+ response,
61
+ newPassword
62
+ } = await window.utils.request('/api/auth/complete', {
63
+ method: 'POST',
64
+ headers: {
65
+ 'Content-Type': 'application/json'
66
+ },
67
+ body: JSON.stringify({
68
+ callbackInput: this.callbackInput,
69
+ state: this.authState
70
+ })
71
+ }, store.webuiPassword);
72
+ if (newPassword) store.webuiPassword = newPassword;
73
+ const data = await response.json();
74
+ if (data.status === 'ok') {
75
+ store.showToast(store.t('accountAddedSuccess'), 'success');
76
+ Alpine.store('data').fetchData();
77
+ document.getElementById('add_account_modal').close();
78
+ this.resetState();
79
+ } else {
80
+ store.showToast(data.error || store.t('authFailed'), 'error');
81
+ }
82
+ } catch (e) {
83
+ Alpine.store('global').showToast(e.message, 'error');
84
+ } finally {
85
+ this.submitting = false;
86
+ }
87
+ }
88
+ });
public/js/components/claude-config.js ADDED
@@ -0,0 +1,508 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Claude Config Component
3
+ * Registers itself to window.Components for Alpine.js to consume
4
+ */
5
+ window.Components = window.Components || {};
6
+
7
+ window.Components.claudeConfig = () => ({
8
+ config: { env: {} },
9
+ configPath: '', // Dynamic path from backend
10
+ models: [],
11
+ loading: false,
12
+ restoring: false,
13
+ gemini1mSuffix: false,
14
+
15
+ // Mode toggle state (proxy/paid)
16
+ currentMode: 'proxy', // 'proxy' or 'paid'
17
+ modeLoading: false,
18
+
19
+ /**
20
+ * Extract port from ANTHROPIC_BASE_URL for display
21
+ * @returns {string} Port number or '8080' as fallback
22
+ */
23
+ getProxyPort() {
24
+ const baseUrl = this.config?.env?.ANTHROPIC_BASE_URL || '';
25
+ try {
26
+ const url = new URL(baseUrl);
27
+ return url.port || '8080';
28
+ } catch {
29
+ return '8080';
30
+ }
31
+ },
32
+
33
+ // Presets state
34
+ presets: [],
35
+ selectedPresetName: '',
36
+ savingPreset: false,
37
+ deletingPreset: false,
38
+ pendingPresetName: '', // For unsaved changes confirmation
39
+ newPresetName: '', // For save preset modal input
40
+
41
+ // Model fields that may contain Gemini model names
42
+ geminiModelFields: [
43
+ 'ANTHROPIC_MODEL',
44
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
45
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
46
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
47
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL'
48
+ ],
49
+
50
+ init() {
51
+ // Only fetch config if this is the active sub-tab
52
+ if (this.$store.global.settingsTab === 'claude') {
53
+ this.fetchConfig();
54
+ this.fetchPresets();
55
+ this.fetchMode();
56
+ }
57
+
58
+ // Watch settings sub-tab (skip initial trigger)
59
+ this.$watch('$store.global.settingsTab', (tab, oldTab) => {
60
+ if (tab === 'claude' && oldTab !== undefined) {
61
+ this.fetchConfig();
62
+ this.fetchPresets();
63
+ this.fetchMode();
64
+ }
65
+ });
66
+
67
+ this.$watch('$store.data.models', (val) => {
68
+ this.models = val || [];
69
+ });
70
+ this.models = Alpine.store('data').models || [];
71
+ },
72
+
73
+ /**
74
+ * Detect if any Gemini model has [1m] suffix
75
+ */
76
+ detectGemini1mSuffix() {
77
+ for (const field of this.geminiModelFields) {
78
+ const val = this.config.env[field];
79
+ if (val && val.toLowerCase().includes('gemini') && val.includes('[1m]')) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ },
85
+
86
+ /**
87
+ * Toggle [1m] suffix for all Gemini models
88
+ */
89
+ toggleGemini1mSuffix(enabled) {
90
+ for (const field of this.geminiModelFields) {
91
+ const val = this.config.env[field];
92
+ // Fix: Case-insensitive check for gemini
93
+ if (val && /gemini/i.test(val)) {
94
+ if (enabled && !val.includes('[1m]')) {
95
+ this.config.env[field] = val.trim() + '[1m]';
96
+ } else if (!enabled && val.includes('[1m]')) {
97
+ this.config.env[field] = val.replace(/\s*\[1m\]$/i, '').trim();
98
+ }
99
+ }
100
+ }
101
+ this.gemini1mSuffix = enabled;
102
+ },
103
+
104
+ /**
105
+ * Helper to select a model from the dropdown
106
+ * @param {string} field - The config.env field to update
107
+ * @param {string} modelId - The selected model ID
108
+ */
109
+ selectModel(field, modelId) {
110
+ if (!this.config.env) this.config.env = {};
111
+
112
+ let finalModelId = modelId;
113
+ // If 1M mode is enabled and it's a Gemini model, append the suffix
114
+ if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) {
115
+ if (!finalModelId.includes('[1m]')) {
116
+ finalModelId = finalModelId.trim() + '[1m]';
117
+ }
118
+ }
119
+
120
+ this.config.env[field] = finalModelId;
121
+ },
122
+
123
+ async fetchConfig() {
124
+ const password = Alpine.store('global').webuiPassword;
125
+ try {
126
+ const { response, newPassword } = await window.utils.request('/api/claude/config', {}, password);
127
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
128
+
129
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
130
+ const data = await response.json();
131
+ this.config = data.config || {};
132
+ this.configPath = data.path || '~/.claude/settings.json'; // Save dynamic path
133
+ if (!this.config.env) this.config.env = {};
134
+
135
+ // Default MCP CLI to true if not set
136
+ if (this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI === undefined) {
137
+ this.config.env.ENABLE_EXPERIMENTAL_MCP_CLI = 'true';
138
+ }
139
+
140
+ // Detect existing [1m] suffix state, default to true
141
+ const hasExistingSuffix = this.detectGemini1mSuffix();
142
+ const hasGeminiModels = this.geminiModelFields.some(f =>
143
+ this.config.env[f]?.toLowerCase().includes('gemini')
144
+ );
145
+
146
+ // Default to enabled: if no suffix found but Gemini models exist, apply suffix
147
+ if (!hasExistingSuffix && hasGeminiModels) {
148
+ this.toggleGemini1mSuffix(true);
149
+ } else {
150
+ this.gemini1mSuffix = hasExistingSuffix || !hasGeminiModels;
151
+ }
152
+ } catch (e) {
153
+ console.error('Failed to fetch Claude config:', e);
154
+ }
155
+ },
156
+
157
+ async saveClaudeConfig() {
158
+ this.loading = true;
159
+ const password = Alpine.store('global').webuiPassword;
160
+ try {
161
+ const { response, newPassword } = await window.utils.request('/api/claude/config', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ body: JSON.stringify(this.config)
165
+ }, password);
166
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
167
+
168
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
169
+ Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigSaved'), 'success');
170
+ } catch (e) {
171
+ Alpine.store('global').showToast(Alpine.store('global').t('saveConfigFailed') + ': ' + e.message, 'error');
172
+ } finally {
173
+ this.loading = false;
174
+ }
175
+ },
176
+
177
+ restoreDefaultClaudeConfig() {
178
+ document.getElementById('restore_defaults_modal').showModal();
179
+ },
180
+
181
+ async executeRestore() {
182
+ this.restoring = true;
183
+ const password = Alpine.store('global').webuiPassword;
184
+ try {
185
+ const { response, newPassword } = await window.utils.request('/api/claude/config/restore', {
186
+ method: 'POST',
187
+ headers: { 'Content-Type': 'application/json' }
188
+ }, password);
189
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
190
+
191
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
192
+ Alpine.store('global').showToast(Alpine.store('global').t('claudeConfigRestored'), 'success');
193
+
194
+ // Close modal
195
+ document.getElementById('restore_defaults_modal').close();
196
+
197
+ // Reload the config to reflect the changes
198
+ await this.fetchConfig();
199
+ } catch (e) {
200
+ Alpine.store('global').showToast(Alpine.store('global').t('restoreConfigFailed') + ': ' + e.message, 'error');
201
+ } finally {
202
+ this.restoring = false;
203
+ }
204
+ },
205
+
206
+ // ==========================================
207
+ // Presets Management
208
+ // ==========================================
209
+
210
+ /**
211
+ * Fetch all saved presets from the server
212
+ */
213
+ async fetchPresets() {
214
+ const password = Alpine.store('global').webuiPassword;
215
+ try {
216
+ const { response, newPassword } = await window.utils.request('/api/claude/presets', {}, password);
217
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
218
+
219
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
220
+ const data = await response.json();
221
+ if (data.status === 'ok') {
222
+ this.presets = data.presets || [];
223
+ // Auto-select first preset if none selected
224
+ if (this.presets.length > 0 && !this.selectedPresetName) {
225
+ this.selectedPresetName = this.presets[0].name;
226
+ }
227
+ }
228
+ } catch (e) {
229
+ console.error('Failed to fetch presets:', e);
230
+ }
231
+ },
232
+
233
+ /**
234
+ * Load the selected preset into the form (does not save to Claude CLI)
235
+ */
236
+ loadSelectedPreset() {
237
+ const preset = this.presets.find(p => p.name === this.selectedPresetName);
238
+ if (!preset) {
239
+ return;
240
+ }
241
+
242
+ // Merge preset config into current config.env
243
+ this.config.env = { ...this.config.env, ...preset.config };
244
+
245
+ // Update Gemini 1M toggle based on merged config (not just preset)
246
+ this.gemini1mSuffix = this.detectGemini1mSuffix();
247
+
248
+ Alpine.store('global').showToast(
249
+ Alpine.store('global').t('presetLoaded') || `Preset "${preset.name}" loaded. Click "Apply to Claude CLI" to save.`,
250
+ 'success'
251
+ );
252
+ },
253
+
254
+ /**
255
+ * Check if current config matches any saved preset
256
+ * @returns {boolean} True if current config matches a preset
257
+ */
258
+ currentConfigMatchesPreset() {
259
+ const relevantKeys = [
260
+ 'ANTHROPIC_BASE_URL',
261
+ 'ANTHROPIC_AUTH_TOKEN',
262
+ 'ANTHROPIC_MODEL',
263
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
264
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
265
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
266
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
267
+ 'ENABLE_EXPERIMENTAL_MCP_CLI'
268
+ ];
269
+
270
+ for (const preset of this.presets) {
271
+ let matches = true;
272
+ for (const key of relevantKeys) {
273
+ const currentVal = this.config.env[key] || '';
274
+ const presetVal = preset.config[key] || '';
275
+ if (currentVal !== presetVal) {
276
+ matches = false;
277
+ break;
278
+ }
279
+ }
280
+ if (matches) return true;
281
+ }
282
+ return false;
283
+ },
284
+
285
+ /**
286
+ * Handle preset selection change - auto-load with unsaved changes warning
287
+ * @param {string} newPresetName - The newly selected preset name
288
+ */
289
+ async onPresetSelect(newPresetName) {
290
+ if (!newPresetName || newPresetName === this.selectedPresetName) return;
291
+
292
+ // Check if current config has unsaved changes (doesn't match any preset)
293
+ const hasUnsavedChanges = !this.currentConfigMatchesPreset();
294
+
295
+ if (hasUnsavedChanges) {
296
+ // Store pending preset and show confirmation modal
297
+ this.pendingPresetName = newPresetName;
298
+ document.getElementById('unsaved_changes_modal').showModal();
299
+ return;
300
+ }
301
+
302
+ this.selectedPresetName = newPresetName;
303
+ this.loadSelectedPreset();
304
+ },
305
+
306
+ /**
307
+ * Confirm loading preset despite unsaved changes
308
+ */
309
+ confirmLoadPreset() {
310
+ document.getElementById('unsaved_changes_modal').close();
311
+ this.selectedPresetName = this.pendingPresetName;
312
+ this.pendingPresetName = '';
313
+ this.loadSelectedPreset();
314
+ },
315
+
316
+ /**
317
+ * Cancel loading preset - revert dropdown selection
318
+ */
319
+ cancelLoadPreset() {
320
+ document.getElementById('unsaved_changes_modal').close();
321
+ // Revert the dropdown to current selection
322
+ const select = document.querySelector('[aria-label="Select preset"]');
323
+ if (select) select.value = this.selectedPresetName;
324
+ this.pendingPresetName = '';
325
+ },
326
+
327
+ /**
328
+ * Save the current config as a new preset
329
+ */
330
+ async saveCurrentAsPreset() {
331
+ // Clear the input and show the save preset modal
332
+ this.newPresetName = '';
333
+ document.getElementById('save_preset_modal').showModal();
334
+ },
335
+
336
+ /**
337
+ * Execute preset save after user enters name
338
+ */
339
+ async executeSavePreset(name) {
340
+ if (!name || !name.trim()) {
341
+ Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired'), 'error');
342
+ return;
343
+ }
344
+
345
+ this.savingPreset = true;
346
+ const password = Alpine.store('global').webuiPassword;
347
+
348
+ try {
349
+ // Save only relevant env vars
350
+ const relevantKeys = [
351
+ 'ANTHROPIC_BASE_URL',
352
+ 'ANTHROPIC_AUTH_TOKEN',
353
+ 'ANTHROPIC_MODEL',
354
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
355
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
356
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
357
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
358
+ 'ENABLE_EXPERIMENTAL_MCP_CLI'
359
+ ];
360
+ const presetConfig = {};
361
+ relevantKeys.forEach(k => {
362
+ if (this.config.env[k]) {
363
+ presetConfig[k] = this.config.env[k];
364
+ }
365
+ });
366
+
367
+ const { response, newPassword } = await window.utils.request('/api/claude/presets', {
368
+ method: 'POST',
369
+ headers: { 'Content-Type': 'application/json' },
370
+ body: JSON.stringify({ name: name.trim(), config: presetConfig })
371
+ }, password);
372
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
373
+
374
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
375
+ const data = await response.json();
376
+ if (data.status === 'ok') {
377
+ this.presets = data.presets || [];
378
+ this.selectedPresetName = name.trim();
379
+ this.newPresetName = ''; // Clear the input
380
+ Alpine.store('global').showToast(
381
+ Alpine.store('global').t('presetSaved') || `Preset "${name}" saved`,
382
+ 'success'
383
+ );
384
+ document.getElementById('save_preset_modal').close();
385
+ } else {
386
+ throw new Error(data.error || Alpine.store('global').t('saveFailed'));
387
+ }
388
+ } catch (e) {
389
+ Alpine.store('global').showToast(Alpine.store('global').t('failedToSavePreset') + ': ' + e.message, 'error');
390
+ } finally {
391
+ this.savingPreset = false;
392
+ }
393
+ },
394
+
395
+ /**
396
+ * Delete the selected preset
397
+ */
398
+ async deleteSelectedPreset() {
399
+ if (!this.selectedPresetName) {
400
+ Alpine.store('global').showToast(Alpine.store('global').t('noPresetSelected'), 'warning');
401
+ return;
402
+ }
403
+
404
+ // Confirm deletion
405
+ const confirmMsg = Alpine.store('global').t('deletePresetConfirm', { name: this.selectedPresetName });
406
+ if (!confirm(confirmMsg)) {
407
+ return;
408
+ }
409
+
410
+ this.deletingPreset = true;
411
+ const password = Alpine.store('global').webuiPassword;
412
+
413
+ try {
414
+ const { response, newPassword } = await window.utils.request(
415
+ `/api/claude/presets/${encodeURIComponent(this.selectedPresetName)}`,
416
+ { method: 'DELETE' },
417
+ password
418
+ );
419
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
420
+
421
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
422
+ const data = await response.json();
423
+ if (data.status === 'ok') {
424
+ this.presets = data.presets || [];
425
+ // Select first available preset or clear selection
426
+ this.selectedPresetName = this.presets.length > 0 ? this.presets[0].name : '';
427
+ Alpine.store('global').showToast(
428
+ Alpine.store('global').t('presetDeleted') || 'Preset deleted',
429
+ 'success'
430
+ );
431
+ } else {
432
+ throw new Error(data.error || Alpine.store('global').t('deleteFailed'));
433
+ }
434
+ } catch (e) {
435
+ Alpine.store('global').showToast(Alpine.store('global').t('failedToDeletePreset') + ': ' + e.message, 'error');
436
+ } finally {
437
+ this.deletingPreset = false;
438
+ }
439
+ },
440
+
441
+ // ==========================================
442
+ // Mode Toggle (Proxy/Paid)
443
+ // ==========================================
444
+
445
+ /**
446
+ * Fetch current mode from server
447
+ */
448
+ async fetchMode() {
449
+ const password = Alpine.store('global').webuiPassword;
450
+ try {
451
+ const { response, newPassword } = await window.utils.request('/api/claude/mode', {}, password);
452
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
453
+
454
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
455
+ const data = await response.json();
456
+ if (data.status === 'ok') {
457
+ this.currentMode = data.mode;
458
+ }
459
+ } catch (e) {
460
+ console.error('Failed to fetch mode:', e);
461
+ }
462
+ },
463
+
464
+ /**
465
+ * Toggle between proxy and paid mode
466
+ * @param {string} newMode - Target mode ('proxy' or 'paid')
467
+ */
468
+ async toggleMode(newMode) {
469
+ if (this.modeLoading || newMode === this.currentMode) return;
470
+
471
+ this.modeLoading = true;
472
+ const password = Alpine.store('global').webuiPassword;
473
+
474
+ try {
475
+ const { response, newPassword } = await window.utils.request('/api/claude/mode', {
476
+ method: 'POST',
477
+ headers: { 'Content-Type': 'application/json' },
478
+ body: JSON.stringify({ mode: newMode })
479
+ }, password);
480
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
481
+
482
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
483
+ const data = await response.json();
484
+
485
+ if (data.status === 'ok') {
486
+ this.currentMode = data.mode;
487
+ if (data.config) {
488
+ this.config = data.config;
489
+ if (!this.config.env) this.config.env = {};
490
+ }
491
+ Alpine.store('global').showToast(data.message, 'success');
492
+
493
+ // Refresh the config and mode state
494
+ await this.fetchConfig();
495
+ await this.fetchMode();
496
+ } else {
497
+ throw new Error(data.error || 'Failed to switch mode');
498
+ }
499
+ } catch (e) {
500
+ Alpine.store('global').showToast(
501
+ (Alpine.store('global').t('modeToggleFailed') || 'Failed to switch mode') + ': ' + e.message,
502
+ 'error'
503
+ );
504
+ } finally {
505
+ this.modeLoading = false;
506
+ }
507
+ }
508
+ });
public/js/components/dashboard.js ADDED
@@ -0,0 +1,323 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Component (Refactored)
3
+ * Orchestrates stats, charts, and filters modules
4
+ * Registers itself to window.Components for Alpine.js to consume
5
+ */
6
+ window.Components = window.Components || {};
7
+
8
+ window.Components.dashboard = () => ({
9
+ // Core state
10
+ stats: { total: 0, active: 0, limited: 0, overallHealth: 0, hasTrendData: false },
11
+ hasFilteredTrendData: true,
12
+ charts: { quotaDistribution: null, usageTrend: null },
13
+ usageStats: { total: 0, today: 0, thisHour: 0 },
14
+ historyData: {},
15
+ modelTree: {},
16
+ families: [],
17
+
18
+ // Claude config status
19
+ claudeConfigStatus: {
20
+ needsApply: false,
21
+ presetName: '',
22
+ checked: false,
23
+ lastCheckedAt: 0
24
+ },
25
+
26
+ // Filter state (from module)
27
+ ...window.DashboardFilters.getInitialState(),
28
+
29
+ // Debounced chart update to prevent rapid successive updates
30
+ _debouncedUpdateTrendChart: null,
31
+
32
+ init() {
33
+ // Create debounced version of updateTrendChart (300ms delay for stability)
34
+ this._debouncedUpdateTrendChart = window.utils.debounce(() => {
35
+ window.DashboardCharts.updateTrendChart(this);
36
+ }, 300);
37
+
38
+ // Load saved preferences from localStorage
39
+ window.DashboardFilters.loadPreferences(this);
40
+
41
+ // Check Claude config status on init
42
+ this.checkClaudeConfigStatus();
43
+
44
+ // Update stats when dashboard becomes active (skip initial trigger)
45
+ this.$watch('$store.global.activeTab', (val, oldVal) => {
46
+ if (val === 'dashboard' && oldVal !== undefined) {
47
+ this.$nextTick(() => {
48
+ this.updateStats();
49
+ this.updateCharts();
50
+ this.updateTrendChart();
51
+ this.checkClaudeConfigStatus();
52
+ });
53
+ }
54
+ });
55
+
56
+ // Watch for data changes
57
+ this.$watch('$store.data.accounts', () => {
58
+ if (this.$store.global.activeTab === 'dashboard') {
59
+ this.updateStats();
60
+ // Debounce chart updates to prevent rapid flickering
61
+ if (this._debouncedUpdateCharts) {
62
+ this._debouncedUpdateCharts();
63
+ } else {
64
+ this._debouncedUpdateCharts = window.utils.debounce(() => this.updateCharts(), 100);
65
+ this._debouncedUpdateCharts();
66
+ }
67
+ }
68
+ });
69
+
70
+ // Watch for history updates from data-store (automatically loaded with account data)
71
+ this.$watch('$store.data.usageHistory', (newHistory) => {
72
+ if (this.$store.global.activeTab === 'dashboard' && newHistory && Object.keys(newHistory).length > 0) {
73
+ // Optimization: Skip if data hasn't changed (prevents double render on load)
74
+ if (this.historyData && JSON.stringify(newHistory) === JSON.stringify(this.historyData)) {
75
+ return;
76
+ }
77
+
78
+ this.historyData = newHistory;
79
+ this.processHistory(newHistory);
80
+ this.stats.hasTrendData = true;
81
+ }
82
+ });
83
+
84
+ // Initial update if already on dashboard
85
+ // Note: Alpine.store('data') may already have data from cache if initialized before this component
86
+ if (this.$store.global.activeTab === 'dashboard') {
87
+ this.$nextTick(() => {
88
+ this.updateStats();
89
+ this.updateCharts();
90
+
91
+ // Optimization: Only process history if it hasn't been processed yet
92
+ // The usageHistory watcher above will handle updates if data changes
93
+ const history = Alpine.store('data').usageHistory;
94
+ if (history && Object.keys(history).length > 0) {
95
+ // Check if we already have this data to avoid redundant chart update
96
+ if (!this.historyData || JSON.stringify(history) !== JSON.stringify(this.historyData)) {
97
+ this.historyData = history;
98
+ this.processHistory(history);
99
+ this.stats.hasTrendData = true;
100
+ }
101
+ }
102
+ });
103
+ }
104
+ },
105
+
106
+ processHistory(history) {
107
+ // Build model tree from hierarchical data
108
+ const tree = {};
109
+ let total = 0, today = 0, thisHour = 0;
110
+
111
+ const now = new Date();
112
+ const todayStart = new Date(now);
113
+ todayStart.setHours(0, 0, 0, 0);
114
+ const currentHour = new Date(now);
115
+ currentHour.setMinutes(0, 0, 0);
116
+
117
+ Object.entries(history).forEach(([iso, hourData]) => {
118
+ const timestamp = new Date(iso);
119
+
120
+ // Process each family in the hour data
121
+ Object.entries(hourData).forEach(([key, value]) => {
122
+ // Skip metadata keys
123
+ if (key === '_total' || key === 'total') return;
124
+
125
+ // Handle hierarchical format: { claude: { "opus-4-5": 10, "_subtotal": 10 } }
126
+ if (typeof value === 'object' && value !== null) {
127
+ if (!tree[key]) tree[key] = new Set();
128
+
129
+ Object.keys(value).forEach(modelName => {
130
+ if (modelName !== '_subtotal') {
131
+ tree[key].add(modelName);
132
+ }
133
+ });
134
+ }
135
+ });
136
+
137
+ // Calculate totals
138
+ const hourTotal = hourData._total || hourData.total || 0;
139
+ total += hourTotal;
140
+
141
+ if (timestamp >= todayStart) {
142
+ today += hourTotal;
143
+ }
144
+ if (timestamp.getTime() === currentHour.getTime()) {
145
+ thisHour = hourTotal;
146
+ }
147
+ });
148
+
149
+ this.usageStats = { total, today, thisHour };
150
+
151
+ // Convert Sets to sorted arrays
152
+ this.modelTree = {};
153
+ Object.entries(tree).forEach(([family, models]) => {
154
+ this.modelTree[family] = Array.from(models).sort();
155
+ });
156
+ this.families = Object.keys(this.modelTree).sort();
157
+
158
+ // Auto-select new families/models that haven't been configured
159
+ this.autoSelectNew();
160
+
161
+ this.updateTrendChart();
162
+ },
163
+
164
+ // Delegation methods for stats
165
+ updateStats() {
166
+ window.DashboardStats.updateStats(this);
167
+ },
168
+
169
+ // Delegation methods for charts
170
+ updateCharts() {
171
+ window.DashboardCharts.updateCharts(this);
172
+ },
173
+
174
+ updateTrendChart() {
175
+ // Use debounced version to prevent rapid successive updates
176
+ if (this._debouncedUpdateTrendChart) {
177
+ this._debouncedUpdateTrendChart();
178
+ } else {
179
+ // Fallback if debounced version not initialized
180
+ window.DashboardCharts.updateTrendChart(this);
181
+ }
182
+ },
183
+
184
+ // Delegation methods for filters
185
+ loadPreferences() {
186
+ window.DashboardFilters.loadPreferences(this);
187
+ },
188
+
189
+ savePreferences() {
190
+ window.DashboardFilters.savePreferences(this);
191
+ },
192
+
193
+ setDisplayMode(mode) {
194
+ window.DashboardFilters.setDisplayMode(this, mode);
195
+ },
196
+
197
+ setTimeRange(range) {
198
+ window.DashboardFilters.setTimeRange(this, range);
199
+ },
200
+
201
+ getTimeRangeLabel() {
202
+ return window.DashboardFilters.getTimeRangeLabel(this);
203
+ },
204
+
205
+ toggleFamily(family) {
206
+ window.DashboardFilters.toggleFamily(this, family);
207
+ },
208
+
209
+ toggleModel(family, model) {
210
+ window.DashboardFilters.toggleModel(this, family, model);
211
+ },
212
+
213
+ isFamilySelected(family) {
214
+ return window.DashboardFilters.isFamilySelected(this, family);
215
+ },
216
+
217
+ isModelSelected(family, model) {
218
+ return window.DashboardFilters.isModelSelected(this, family, model);
219
+ },
220
+
221
+ selectAll() {
222
+ window.DashboardFilters.selectAll(this);
223
+ },
224
+
225
+ deselectAll() {
226
+ window.DashboardFilters.deselectAll(this);
227
+ },
228
+
229
+ getFamilyColor(family) {
230
+ return window.DashboardFilters.getFamilyColor(family);
231
+ },
232
+
233
+ getModelColor(family, modelIndex) {
234
+ return window.DashboardFilters.getModelColor(family, modelIndex);
235
+ },
236
+
237
+ getSelectedCount() {
238
+ return window.DashboardFilters.getSelectedCount(this);
239
+ },
240
+
241
+ autoSelectNew() {
242
+ window.DashboardFilters.autoSelectNew(this);
243
+ },
244
+
245
+ autoSelectTopN(n = 5) {
246
+ window.DashboardFilters.autoSelectTopN(this, n);
247
+ },
248
+
249
+ /**
250
+ * Check if Claude CLI config needs to be updated.
251
+ * Fetches both local config and presets, then compares against all presets.
252
+ * Skips if already checked within the last 30 seconds.
253
+ */
254
+ async checkClaudeConfigStatus() {
255
+ const now = Date.now();
256
+ if (this.claudeConfigStatus.checked && now - this.claudeConfigStatus.lastCheckedAt < 30000) return;
257
+
258
+ try {
259
+ const password = Alpine.store('global').webuiPassword;
260
+
261
+ const [configRes, presetsRes] = await Promise.all([
262
+ window.utils.request('/api/claude/config', {}, password),
263
+ window.utils.request('/api/claude/presets', {}, password)
264
+ ]);
265
+
266
+ if (!configRes.response.ok || !presetsRes.response.ok) {
267
+ this.claudeConfigStatus.checked = true;
268
+ this.claudeConfigStatus.lastCheckedAt = now;
269
+ return;
270
+ }
271
+
272
+ const configData = await configRes.response.json();
273
+ const presetsData = await presetsRes.response.json();
274
+
275
+ const localConfig = configData.config || { env: {} };
276
+ const presets = presetsData.presets || [];
277
+
278
+ if (presets.length === 0) {
279
+ this.claudeConfigStatus = { needsApply: false, presetName: '', checked: true, lastCheckedAt: now };
280
+ return;
281
+ }
282
+
283
+ const relevantKeys = [
284
+ 'ANTHROPIC_BASE_URL',
285
+ 'ANTHROPIC_AUTH_TOKEN',
286
+ 'ANTHROPIC_MODEL',
287
+ 'CLAUDE_CODE_SUBAGENT_MODEL',
288
+ 'ANTHROPIC_DEFAULT_OPUS_MODEL',
289
+ 'ANTHROPIC_DEFAULT_SONNET_MODEL',
290
+ 'ANTHROPIC_DEFAULT_HAIKU_MODEL',
291
+ 'ENABLE_EXPERIMENTAL_MCP_CLI'
292
+ ];
293
+
294
+ // Check if local config matches ANY preset
295
+ const matchesAnyPreset = presets.some(preset => {
296
+ return relevantKeys.every(key => {
297
+ const localVal = localConfig.env?.[key] || '';
298
+ const presetVal = preset.config?.[key] || '';
299
+ return localVal === presetVal;
300
+ });
301
+ });
302
+
303
+ this.claudeConfigStatus = {
304
+ needsApply: !matchesAnyPreset,
305
+ presetName: presets[0].name,
306
+ checked: true,
307
+ lastCheckedAt: now
308
+ };
309
+ } catch (e) {
310
+ console.error('Failed to check Claude config status:', e);
311
+ this.claudeConfigStatus.checked = true;
312
+ this.claudeConfigStatus.lastCheckedAt = Date.now();
313
+ }
314
+ },
315
+
316
+ /**
317
+ * Navigate to Claude CLI settings and dismiss the warning
318
+ */
319
+ goToClaudeSettings() {
320
+ this.$store.global.activeTab = 'settings';
321
+ this.$store.global.settingsTab = 'claude';
322
+ }
323
+ });
public/js/components/dashboard/charts.js ADDED
@@ -0,0 +1,590 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Charts Module
3
+ * 职责:使用 Chart.js 渲染配额分布图和使用趋势图
4
+ *
5
+ * 调用时机:
6
+ * - dashboard 组件 init() 时初始化图表
7
+ * - 筛选器变化时更新图表数据
8
+ * - $store.data 更新时刷新图表
9
+ *
10
+ * 图表类型:
11
+ * 1. Quota Distribution(饼图):按模型家族或具体模型显示配额分布
12
+ * 2. Usage Trend(折线图):显示历史使用趋势
13
+ *
14
+ * 特殊处理:
15
+ * - 使用 _trendChartUpdateLock 防止并发更新导致的竞争条件
16
+ * - 通过 debounce 优化频繁更新的性能
17
+ * - 响应式处理:移动端自动调整图表大小和标签显示
18
+ *
19
+ * @module DashboardCharts
20
+ */
21
+ window.DashboardCharts = window.DashboardCharts || {};
22
+
23
+ // Helper to get CSS variable values (alias to window.utils.getThemeColor)
24
+ const getThemeColor = (name) => window.utils.getThemeColor(name);
25
+
26
+ // Color palette for different families and models
27
+ const FAMILY_COLORS = {
28
+ get claude() {
29
+ return getThemeColor("--color-neon-purple");
30
+ },
31
+ get gemini() {
32
+ return getThemeColor("--color-neon-green");
33
+ },
34
+ get other() {
35
+ return getThemeColor("--color-neon-cyan");
36
+ },
37
+ };
38
+
39
+ const MODEL_COLORS = Array.from({ length: 16 }, (_, i) =>
40
+ getThemeColor(`--color-chart-${i + 1}`)
41
+ );
42
+
43
+ // Export constants for filter module
44
+ window.DashboardConstants = { FAMILY_COLORS, MODEL_COLORS };
45
+
46
+ // Module-level lock to prevent concurrent chart updates (fixes race condition)
47
+ let _trendChartUpdateLock = false;
48
+
49
+ /**
50
+ * Convert hex color to rgba
51
+ * @param {string} hex - Hex color string
52
+ * @param {number} alpha - Alpha value (0-1)
53
+ * @returns {string} rgba color string
54
+ */
55
+ window.DashboardCharts.hexToRgba = function (hex, alpha) {
56
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
57
+ if (result) {
58
+ return `rgba(${parseInt(result[1], 16)}, ${parseInt(
59
+ result[2],
60
+ 16
61
+ )}, ${parseInt(result[3], 16)}, ${alpha})`;
62
+ }
63
+ return hex;
64
+ };
65
+
66
+ /**
67
+ * Check if canvas is ready for Chart creation
68
+ * @param {HTMLCanvasElement} canvas - Canvas element
69
+ * @returns {boolean} True if canvas is ready
70
+ */
71
+ function isCanvasReady(canvas) {
72
+ if (!canvas || !canvas.isConnected) return false;
73
+ if (canvas.offsetWidth === 0 || canvas.offsetHeight === 0) return false;
74
+
75
+ try {
76
+ const ctx = canvas.getContext("2d");
77
+ return !!ctx;
78
+ } catch (e) {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Create a Chart.js dataset with gradient fill
85
+ * @param {string} label - Dataset label
86
+ * @param {Array} data - Data points
87
+ * @param {string} color - Line color
88
+ * @param {HTMLCanvasElement} canvas - Canvas element
89
+ * @returns {object} Chart.js dataset configuration
90
+ */
91
+ window.DashboardCharts.createDataset = function (label, data, color, canvas) {
92
+ let gradient;
93
+
94
+ try {
95
+ // Safely create gradient with fallback
96
+ if (canvas && canvas.getContext) {
97
+ const ctx = canvas.getContext("2d");
98
+ if (ctx && ctx.createLinearGradient) {
99
+ gradient = ctx.createLinearGradient(0, 0, 0, 200);
100
+ gradient.addColorStop(0, window.DashboardCharts.hexToRgba(color, 0.12));
101
+ gradient.addColorStop(
102
+ 0.6,
103
+ window.DashboardCharts.hexToRgba(color, 0.05)
104
+ );
105
+ gradient.addColorStop(1, "rgba(0, 0, 0, 0)");
106
+ }
107
+ }
108
+ } catch (e) {
109
+ if (window.UILogger) window.UILogger.debug("Gradient fallback:", e.message);
110
+ gradient = null;
111
+ }
112
+
113
+ // Fallback to solid color if gradient creation failed
114
+ const backgroundColor =
115
+ gradient || window.DashboardCharts.hexToRgba(color, 0.08);
116
+
117
+ return {
118
+ label,
119
+ data,
120
+ borderColor: color,
121
+ backgroundColor: backgroundColor,
122
+ borderWidth: 2.5,
123
+ tension: 0.35,
124
+ fill: true,
125
+ pointRadius: 2.5,
126
+ pointHoverRadius: 6,
127
+ pointBackgroundColor: color,
128
+ pointBorderColor: "rgba(9, 9, 11, 0.8)",
129
+ pointBorderWidth: 1.5,
130
+ };
131
+ };
132
+
133
+ /**
134
+ * Update quota distribution donut chart
135
+ * @param {object} component - Dashboard component instance
136
+ */
137
+ window.DashboardCharts.updateCharts = function (component) {
138
+ const canvas = document.getElementById("quotaChart");
139
+
140
+ // Safety checks
141
+ if (!canvas) {
142
+ console.debug("quotaChart canvas not found");
143
+ return;
144
+ }
145
+
146
+ // FORCE DESTROY: Check for existing chart on the canvas element property
147
+ // This handles cases where Component state is lost but DOM persists
148
+ if (canvas._chartInstance) {
149
+ console.debug("Destroying existing quota chart from canvas property");
150
+ try {
151
+ canvas._chartInstance.destroy();
152
+ } catch(e) { if (window.UILogger) window.UILogger.debug(e); }
153
+ canvas._chartInstance = null;
154
+ }
155
+
156
+ // Also check component state as backup
157
+ if (component.charts.quotaDistribution) {
158
+ try {
159
+ component.charts.quotaDistribution.destroy();
160
+ } catch(e) { }
161
+ component.charts.quotaDistribution = null;
162
+ }
163
+
164
+ // Also try Chart.js registry
165
+ if (typeof Chart !== "undefined" && Chart.getChart) {
166
+ const regChart = Chart.getChart(canvas);
167
+ if (regChart) {
168
+ try { regChart.destroy(); } catch(e) {}
169
+ }
170
+ }
171
+
172
+ if (typeof Chart === "undefined") {
173
+ if (window.UILogger) window.UILogger.warn("Chart.js not loaded");
174
+ return;
175
+ }
176
+ if (!isCanvasReady(canvas)) {
177
+ if (window.UILogger) window.UILogger.debug("quotaChart canvas not ready, skipping update");
178
+ return;
179
+ }
180
+
181
+ // Use UNFILTERED data for global health chart
182
+ const rows = Alpine.store("data").getUnfilteredQuotaData();
183
+ if (!rows || rows.length === 0) return;
184
+
185
+ const healthByFamily = {};
186
+ let totalHealthSum = 0;
187
+ let totalModelCount = 0;
188
+
189
+ rows.forEach((row) => {
190
+ const family = row.family || "unknown";
191
+ if (!healthByFamily[family]) {
192
+ healthByFamily[family] = { total: 0, weighted: 0 };
193
+ }
194
+
195
+ // Calculate average health from quotaInfo (each entry has { pct })
196
+ // Health = average of all account quotas for this model
197
+ const quotaInfo = row.quotaInfo || [];
198
+ let avgHealth = 0;
199
+
200
+ if (quotaInfo.length > 0) {
201
+ avgHealth = quotaInfo.reduce((sum, q) => sum + (q.pct || 0), 0) / quotaInfo.length;
202
+ }
203
+ // If quotaInfo is empty, avgHealth remains 0 (depleted/unknown)
204
+
205
+ healthByFamily[family].total++;
206
+ healthByFamily[family].weighted += avgHealth;
207
+ totalHealthSum += avgHealth;
208
+ totalModelCount++;
209
+ });
210
+
211
+ // Update overall health for dashboard display
212
+ component.stats.overallHealth = totalModelCount > 0
213
+ ? Math.round(totalHealthSum / totalModelCount)
214
+ : 0;
215
+
216
+ const familyColors = {
217
+ claude: getThemeColor("--color-neon-purple") || "#a855f7",
218
+ gemini: getThemeColor("--color-neon-green") || "#22c55e",
219
+ unknown: getThemeColor("--color-neon-cyan") || "#06b6d4",
220
+ };
221
+
222
+ const data = [];
223
+ const colors = [];
224
+ const labels = [];
225
+
226
+ const totalFamilies = Object.keys(healthByFamily).length;
227
+ const segmentSize = 100 / totalFamilies;
228
+
229
+ Object.entries(healthByFamily).forEach(([family, { total, weighted }]) => {
230
+ const health = weighted / total;
231
+ const activeVal = (health / 100) * segmentSize;
232
+ const inactiveVal = segmentSize - activeVal;
233
+
234
+ const familyColor = familyColors[family] || familyColors["unknown"];
235
+
236
+ // Get translation keys
237
+ const store = Alpine.store("global");
238
+ const familyKey =
239
+ "family" + family.charAt(0).toUpperCase() + family.slice(1);
240
+ const familyName = store.t(familyKey);
241
+
242
+ // Labels using translations if possible
243
+ const activeLabel =
244
+ family === "claude"
245
+ ? store.t("claudeActive")
246
+ : family === "gemini"
247
+ ? store.t("geminiActive")
248
+ : `${familyName} ${store.t("activeSuffix")}`;
249
+
250
+ const depletedLabel =
251
+ family === "claude"
252
+ ? store.t("claudeEmpty")
253
+ : family === "gemini"
254
+ ? store.t("geminiEmpty")
255
+ : `${familyName} ${store.t("depleted")}`;
256
+
257
+ // Active segment
258
+ data.push(activeVal);
259
+ colors.push(familyColor);
260
+ labels.push(activeLabel);
261
+
262
+ // Inactive segment
263
+ data.push(inactiveVal);
264
+ // Use higher opacity (0.6) to ensure the ring color matches the legend more closely
265
+ // while still differentiating "depleted" from "active" (1.0 opacity)
266
+ colors.push(window.DashboardCharts.hexToRgba(familyColor, 0.6));
267
+ labels.push(depletedLabel);
268
+ });
269
+
270
+ // Create Chart
271
+ try {
272
+ const newChart = new Chart(canvas, {
273
+ // ... config
274
+ type: "doughnut",
275
+ data: {
276
+ labels: labels,
277
+ datasets: [
278
+ {
279
+ data: data,
280
+ backgroundColor: colors,
281
+ borderColor: getThemeColor("--color-space-950"),
282
+ borderWidth: 0,
283
+ hoverOffset: 0,
284
+ borderRadius: 0,
285
+ },
286
+ ],
287
+ },
288
+ options: {
289
+ responsive: true,
290
+ maintainAspectRatio: false,
291
+ cutout: "85%",
292
+ rotation: -90,
293
+ circumference: 360,
294
+ plugins: {
295
+ legend: { display: false },
296
+ tooltip: { enabled: false },
297
+ title: { display: false },
298
+ },
299
+ animation: {
300
+ // Disable animation for quota chart to prevent "double refresh" visual glitch
301
+ duration: 0
302
+ },
303
+ },
304
+ });
305
+
306
+ // SAVE INSTANCE TO CANVAS AND COMPONENT
307
+ canvas._chartInstance = newChart;
308
+ component.charts.quotaDistribution = newChart;
309
+
310
+ } catch (e) {
311
+ console.error("Failed to create quota chart:", e);
312
+ }
313
+ };
314
+
315
+ /**
316
+ * Update usage trend line chart
317
+ * @param {object} component - Dashboard component instance
318
+ */
319
+ window.DashboardCharts.updateTrendChart = function (component) {
320
+ // Prevent concurrent updates (fixes race condition on rapid toggling)
321
+ if (_trendChartUpdateLock) {
322
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Update already in progress, skipping");
323
+ return;
324
+ }
325
+ _trendChartUpdateLock = true;
326
+
327
+ const logger = window.UILogger || console;
328
+ logger.debug("[updateTrendChart] Starting update...");
329
+
330
+ const canvas = document.getElementById("usageTrendChart");
331
+
332
+ // FORCE DESTROY: Check for existing chart on the canvas element property
333
+ if (canvas) {
334
+ if (canvas._chartInstance) {
335
+ console.debug("Destroying existing trend chart from canvas property");
336
+ try {
337
+ canvas._chartInstance.stop();
338
+ canvas._chartInstance.destroy();
339
+ } catch(e) { if (window.UILogger) window.UILogger.debug(e); }
340
+ canvas._chartInstance = null;
341
+ }
342
+
343
+ // Also try Chart.js registry
344
+ if (typeof Chart !== "undefined" && Chart.getChart) {
345
+ const regChart = Chart.getChart(canvas);
346
+ if (regChart) {
347
+ try { regChart.stop(); regChart.destroy(); } catch(e) {}
348
+ }
349
+ }
350
+ }
351
+
352
+ // Also check component state
353
+ if (component.charts.usageTrend) {
354
+ try {
355
+ component.charts.usageTrend.stop();
356
+ component.charts.usageTrend.destroy();
357
+ } catch (e) { }
358
+ component.charts.usageTrend = null;
359
+ }
360
+
361
+ // Safety checks
362
+ if (!canvas) {
363
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not found in DOM");
364
+ _trendChartUpdateLock = false;
365
+ return;
366
+ }
367
+ if (typeof Chart === "undefined") {
368
+ if (window.UILogger) window.UILogger.warn("[updateTrendChart] Chart.js not loaded");
369
+ _trendChartUpdateLock = false;
370
+ return;
371
+ }
372
+
373
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas element:", {
374
+ exists: !!canvas,
375
+ isConnected: canvas.isConnected,
376
+ width: canvas.offsetWidth,
377
+ height: canvas.offsetHeight,
378
+ parentElement: canvas.parentElement?.tagName,
379
+ });
380
+
381
+ if (!isCanvasReady(canvas)) {
382
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Canvas not ready", {
383
+ isConnected: canvas.isConnected,
384
+ width: canvas.offsetWidth,
385
+ height: canvas.offsetHeight,
386
+ });
387
+ _trendChartUpdateLock = false;
388
+ return;
389
+ }
390
+
391
+ // Clear canvas to ensure clean state after destroy
392
+ try {
393
+ const ctx = canvas.getContext("2d");
394
+ if (ctx) {
395
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
396
+ }
397
+ } catch (e) {
398
+ if (window.UILogger) window.UILogger.debug("[updateTrendChart] Failed to clear canvas:", e.message);
399
+ }
400
+
401
+ if (window.UILogger) window.UILogger.debug(
402
+ "[updateTrendChart] Canvas is ready, proceeding with chart creation"
403
+ );
404
+
405
+ // Use filtered history data based on time range
406
+ const history = window.DashboardFilters.getFilteredHistoryData(component);
407
+ if (!history || Object.keys(history).length === 0) {
408
+ if (window.UILogger) window.UILogger.debug("No history data available for trend chart (after filtering)");
409
+ component.hasFilteredTrendData = false;
410
+ _trendChartUpdateLock = false;
411
+ return;
412
+ }
413
+
414
+ component.hasFilteredTrendData = true;
415
+
416
+ // Sort entries by timestamp for correct order
417
+ const sortedEntries = Object.entries(history).sort(
418
+ ([a], [b]) => new Date(a).getTime() - new Date(b).getTime()
419
+ );
420
+
421
+ // Determine if data spans multiple days (for smart label formatting)
422
+ const timestamps = sortedEntries.map(([iso]) => new Date(iso));
423
+ const isMultiDay = timestamps.length > 1 &&
424
+ timestamps[0].toDateString() !== timestamps[timestamps.length - 1].toDateString();
425
+
426
+ // Helper to format X-axis labels based on time range and multi-day status
427
+ const formatLabel = (date) => {
428
+ const timeRange = component.timeRange || '24h';
429
+
430
+ if (timeRange === '7d') {
431
+ // Week view: show MM/DD
432
+ return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' });
433
+ } else if (isMultiDay || timeRange === 'all') {
434
+ // Multi-day data: show MM/DD HH:MM
435
+ return date.toLocaleDateString([], { month: '2-digit', day: '2-digit' }) + ' ' +
436
+ date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
437
+ } else {
438
+ // Same day: show HH:MM only
439
+ return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
440
+ }
441
+ };
442
+
443
+ const labels = [];
444
+ const datasets = [];
445
+
446
+ if (component.displayMode === "family") {
447
+ // Aggregate by family
448
+ const dataByFamily = {};
449
+ component.selectedFamilies.forEach((family) => {
450
+ dataByFamily[family] = [];
451
+ });
452
+
453
+ sortedEntries.forEach(([iso, hourData]) => {
454
+ const date = new Date(iso);
455
+ labels.push(formatLabel(date));
456
+
457
+ component.selectedFamilies.forEach((family) => {
458
+ const familyData = hourData[family];
459
+ const count = familyData?._subtotal || 0;
460
+ dataByFamily[family].push(count);
461
+ });
462
+ });
463
+
464
+ // Build datasets for families
465
+ component.selectedFamilies.forEach((family) => {
466
+ const color = window.DashboardFilters.getFamilyColor(family);
467
+ const familyKey =
468
+ "family" + family.charAt(0).toUpperCase() + family.slice(1);
469
+ const label = Alpine.store("global").t(familyKey);
470
+ datasets.push(
471
+ window.DashboardCharts.createDataset(
472
+ label,
473
+ dataByFamily[family],
474
+ color,
475
+ canvas
476
+ )
477
+ );
478
+ });
479
+ } else {
480
+ // Show individual models
481
+ const dataByModel = {};
482
+
483
+ // Initialize data arrays
484
+ component.families.forEach((family) => {
485
+ (component.selectedModels[family] || []).forEach((model) => {
486
+ const key = `${family}:${model}`;
487
+ dataByModel[key] = [];
488
+ });
489
+ });
490
+
491
+ sortedEntries.forEach(([iso, hourData]) => {
492
+ const date = new Date(iso);
493
+ labels.push(formatLabel(date));
494
+
495
+ component.families.forEach((family) => {
496
+ const familyData = hourData[family] || {};
497
+ (component.selectedModels[family] || []).forEach((model) => {
498
+ const key = `${family}:${model}`;
499
+ dataByModel[key].push(familyData[model] || 0);
500
+ });
501
+ });
502
+ });
503
+
504
+ // Build datasets for models
505
+ component.families.forEach((family) => {
506
+ (component.selectedModels[family] || []).forEach((model, modelIndex) => {
507
+ const key = `${family}:${model}`;
508
+ const color = window.DashboardFilters.getModelColor(family, modelIndex);
509
+ datasets.push(
510
+ window.DashboardCharts.createDataset(
511
+ model,
512
+ dataByModel[key],
513
+ color,
514
+ canvas
515
+ )
516
+ );
517
+ });
518
+ });
519
+ }
520
+
521
+ try {
522
+ const newChart = new Chart(canvas, {
523
+ type: "line",
524
+ data: { labels, datasets },
525
+ options: {
526
+ responsive: true,
527
+ maintainAspectRatio: false,
528
+ animation: {
529
+ duration: 300, // Reduced animation for faster updates
530
+ },
531
+ interaction: {
532
+ mode: "index",
533
+ intersect: false,
534
+ },
535
+ plugins: {
536
+ legend: { display: false },
537
+ tooltip: {
538
+ backgroundColor:
539
+ getThemeColor("--color-space-950") || "rgba(24, 24, 27, 0.9)",
540
+ titleColor: getThemeColor("--color-text-main"),
541
+ bodyColor: getThemeColor("--color-text-bright"),
542
+ borderColor: getThemeColor("--color-space-border"),
543
+ borderWidth: 1,
544
+ padding: 10,
545
+ displayColors: true,
546
+ callbacks: {
547
+ label: function (context) {
548
+ return context.dataset.label + ": " + context.parsed.y;
549
+ },
550
+ },
551
+ },
552
+ },
553
+ scales: {
554
+ x: {
555
+ display: true,
556
+ grid: { display: false },
557
+ ticks: {
558
+ color: getThemeColor("--color-text-muted"),
559
+ font: { size: 10 },
560
+ },
561
+ },
562
+ y: {
563
+ display: true,
564
+ beginAtZero: true,
565
+ grid: {
566
+ display: true,
567
+ color:
568
+ getThemeColor("--color-space-border") + "1a" ||
569
+ "rgba(255,255,255,0.05)",
570
+ },
571
+ ticks: {
572
+ color: getThemeColor("--color-text-muted"),
573
+ font: { size: 10 },
574
+ },
575
+ },
576
+ },
577
+ },
578
+ });
579
+
580
+ // SAVE INSTANCE
581
+ canvas._chartInstance = newChart;
582
+ component.charts.usageTrend = newChart;
583
+
584
+ } catch (e) {
585
+ console.error("Failed to create trend chart:", e);
586
+ } finally {
587
+ // Always release lock
588
+ _trendChartUpdateLock = false;
589
+ }
590
+ };
public/js/components/dashboard/filters.js ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Filters Module
3
+ * 职责:管理图表筛选器的状态和持久化
4
+ *
5
+ * 功能:
6
+ * 1. 时间范围筛选(1h/6h/24h/7d/all)
7
+ * 2. 显示模式切换(按家族/按模型)
8
+ * 3. 模型/家族多选筛选
9
+ * 4. 筛选器状态持久化到 localStorage
10
+ *
11
+ * 调用时机:
12
+ * - 组件初始化时加载用户偏好
13
+ * - 筛选器变化时保存并触发图表更新
14
+ *
15
+ * 持久化键:
16
+ * - localStorage['dashboard_chart_prefs']
17
+ *
18
+ * @module DashboardFilters
19
+ */
20
+ window.DashboardFilters = window.DashboardFilters || {};
21
+
22
+ /**
23
+ * Get initial filter state
24
+ * @returns {object} Initial state for filter properties
25
+ */
26
+ window.DashboardFilters.getInitialState = function() {
27
+ return {
28
+ timeRange: '24h', // '1h', '6h', '24h', '7d', 'all'
29
+ displayMode: 'model',
30
+ selectedFamilies: [],
31
+ selectedModels: {},
32
+ showModelFilter: false,
33
+ showTimeRangeDropdown: false,
34
+ showDisplayModeDropdown: false
35
+ };
36
+ };
37
+
38
+ /**
39
+ * Load filter preferences from localStorage
40
+ * @param {object} component - Dashboard component instance
41
+ */
42
+ window.DashboardFilters.loadPreferences = function(component) {
43
+ try {
44
+ const saved = localStorage.getItem('dashboard_chart_prefs');
45
+ if (saved) {
46
+ const prefs = JSON.parse(saved);
47
+ component.timeRange = prefs.timeRange || '24h';
48
+ component.displayMode = prefs.displayMode || 'model';
49
+ component.selectedFamilies = prefs.selectedFamilies || [];
50
+ component.selectedModels = prefs.selectedModels || {};
51
+ }
52
+ } catch (e) {
53
+ if (window.UILogger) window.UILogger.debug('Failed to load dashboard preferences:', e.message);
54
+ }
55
+ };
56
+
57
+ /**
58
+ * Save filter preferences to localStorage
59
+ * @param {object} component - Dashboard component instance
60
+ */
61
+ window.DashboardFilters.savePreferences = function(component) {
62
+ try {
63
+ localStorage.setItem('dashboard_chart_prefs', JSON.stringify({
64
+ timeRange: component.timeRange,
65
+ displayMode: component.displayMode,
66
+ selectedFamilies: component.selectedFamilies,
67
+ selectedModels: component.selectedModels
68
+ }));
69
+ } catch (e) {
70
+ if (window.UILogger) window.UILogger.debug('Failed to save dashboard preferences:', e.message);
71
+ }
72
+ };
73
+
74
+ /**
75
+ * Set display mode (family or model)
76
+ * @param {object} component - Dashboard component instance
77
+ * @param {string} mode - 'family' or 'model'
78
+ */
79
+ window.DashboardFilters.setDisplayMode = function(component, mode) {
80
+ component.displayMode = mode;
81
+ component.showDisplayModeDropdown = false;
82
+ window.DashboardFilters.savePreferences(component);
83
+ // updateTrendChart uses debounce internally, call directly
84
+ component.updateTrendChart();
85
+ };
86
+
87
+ /**
88
+ * Set time range filter
89
+ * @param {object} component - Dashboard component instance
90
+ * @param {string} range - '1h', '6h', '24h', '7d', 'all'
91
+ */
92
+ window.DashboardFilters.setTimeRange = function(component, range) {
93
+ component.timeRange = range;
94
+ component.showTimeRangeDropdown = false;
95
+ window.DashboardFilters.savePreferences(component);
96
+ component.updateTrendChart();
97
+ };
98
+
99
+ /**
100
+ * Get time range cutoff timestamp
101
+ * @param {string} range - Time range code
102
+ * @returns {number|null} Cutoff timestamp or null for 'all'
103
+ */
104
+ window.DashboardFilters.getTimeRangeCutoff = function(range) {
105
+ const now = Date.now();
106
+ switch (range) {
107
+ case '1h': return now - 1 * 60 * 60 * 1000;
108
+ case '6h': return now - 6 * 60 * 60 * 1000;
109
+ case '24h': return now - 24 * 60 * 60 * 1000;
110
+ case '7d': return now - 7 * 24 * 60 * 60 * 1000;
111
+ default: return null; // 'all'
112
+ }
113
+ };
114
+
115
+ /**
116
+ * Get filtered history data based on time range
117
+ * @param {object} component - Dashboard component instance
118
+ * @returns {object} Filtered history data
119
+ */
120
+ window.DashboardFilters.getFilteredHistoryData = function(component) {
121
+ const history = component.historyData;
122
+ if (!history || Object.keys(history).length === 0) return {};
123
+
124
+ const cutoff = window.DashboardFilters.getTimeRangeCutoff(component.timeRange);
125
+ if (!cutoff) return history; // 'all' - return everything
126
+
127
+ const filtered = {};
128
+ Object.entries(history).forEach(([iso, data]) => {
129
+ const timestamp = new Date(iso).getTime();
130
+ if (timestamp >= cutoff) {
131
+ filtered[iso] = data;
132
+ }
133
+ });
134
+ return filtered;
135
+ };
136
+
137
+ /**
138
+ * Get time range label for display
139
+ * @param {object} component - Dashboard component instance
140
+ * @returns {string} Translated label
141
+ */
142
+ window.DashboardFilters.getTimeRangeLabel = function(component) {
143
+ const store = Alpine.store('global');
144
+ switch (component.timeRange) {
145
+ case '1h': return store.t('last1Hour');
146
+ case '6h': return store.t('last6Hours');
147
+ case '24h': return store.t('last24Hours');
148
+ case '7d': return store.t('last7Days');
149
+ default: return store.t('allTime');
150
+ }
151
+ };
152
+
153
+ /**
154
+ * Toggle family selection
155
+ * @param {object} component - Dashboard component instance
156
+ * @param {string} family - Family name (e.g., 'claude', 'gemini')
157
+ */
158
+ window.DashboardFilters.toggleFamily = function(component, family) {
159
+ const index = component.selectedFamilies.indexOf(family);
160
+ if (index > -1) {
161
+ component.selectedFamilies.splice(index, 1);
162
+ } else {
163
+ component.selectedFamilies.push(family);
164
+ }
165
+ window.DashboardFilters.savePreferences(component);
166
+ // updateTrendChart uses debounce internally, call directly
167
+ component.updateTrendChart();
168
+ };
169
+
170
+ /**
171
+ * Toggle model selection within a family
172
+ * @param {object} component - Dashboard component instance
173
+ * @param {string} family - Family name
174
+ * @param {string} model - Model name
175
+ */
176
+ window.DashboardFilters.toggleModel = function(component, family, model) {
177
+ if (!component.selectedModels[family]) {
178
+ component.selectedModels[family] = [];
179
+ }
180
+ const index = component.selectedModels[family].indexOf(model);
181
+ if (index > -1) {
182
+ component.selectedModels[family].splice(index, 1);
183
+ } else {
184
+ component.selectedModels[family].push(model);
185
+ }
186
+ window.DashboardFilters.savePreferences(component);
187
+ // updateTrendChart uses debounce internally, call directly
188
+ component.updateTrendChart();
189
+ };
190
+
191
+ /**
192
+ * Check if family is selected
193
+ * @param {object} component - Dashboard component instance
194
+ * @param {string} family - Family name
195
+ * @returns {boolean}
196
+ */
197
+ window.DashboardFilters.isFamilySelected = function(component, family) {
198
+ return component.selectedFamilies.includes(family);
199
+ };
200
+
201
+ /**
202
+ * Check if model is selected
203
+ * @param {object} component - Dashboard component instance
204
+ * @param {string} family - Family name
205
+ * @param {string} model - Model name
206
+ * @returns {boolean}
207
+ */
208
+ window.DashboardFilters.isModelSelected = function(component, family, model) {
209
+ return component.selectedModels[family]?.includes(model) || false;
210
+ };
211
+
212
+ /**
213
+ * Select all families and models
214
+ * @param {object} component - Dashboard component instance
215
+ */
216
+ window.DashboardFilters.selectAll = function(component) {
217
+ component.selectedFamilies = [...component.families];
218
+ component.families.forEach(family => {
219
+ component.selectedModels[family] = [...(component.modelTree[family] || [])];
220
+ });
221
+ window.DashboardFilters.savePreferences(component);
222
+ // updateTrendChart uses debounce internally, call directly
223
+ component.updateTrendChart();
224
+ };
225
+
226
+ /**
227
+ * Deselect all families and models
228
+ * @param {object} component - Dashboard component instance
229
+ */
230
+ window.DashboardFilters.deselectAll = function(component) {
231
+ component.selectedFamilies = [];
232
+ component.selectedModels = {};
233
+ window.DashboardFilters.savePreferences(component);
234
+ // updateTrendChart uses debounce internally, call directly
235
+ component.updateTrendChart();
236
+ };
237
+
238
+ /**
239
+ * Get color for a family
240
+ * @param {string} family - Family name
241
+ * @returns {string} Color value
242
+ */
243
+ window.DashboardFilters.getFamilyColor = function(family) {
244
+ const FAMILY_COLORS = window.DashboardConstants?.FAMILY_COLORS || {};
245
+ return FAMILY_COLORS[family] || FAMILY_COLORS.other;
246
+ };
247
+
248
+ /**
249
+ * Get color for a model (with index for variation within family)
250
+ * @param {string} family - Family name
251
+ * @param {number} modelIndex - Index of model within family
252
+ * @returns {string} Color value
253
+ */
254
+ window.DashboardFilters.getModelColor = function(family, modelIndex) {
255
+ const MODEL_COLORS = window.DashboardConstants?.MODEL_COLORS || [];
256
+ const baseIndex = family === 'claude' ? 0 : (family === 'gemini' ? 4 : 8);
257
+ return MODEL_COLORS[(baseIndex + modelIndex) % MODEL_COLORS.length];
258
+ };
259
+
260
+ /**
261
+ * Get count of selected items for display
262
+ * @param {object} component - Dashboard component instance
263
+ * @returns {string} Selected count string (e.g., "3/5")
264
+ */
265
+ window.DashboardFilters.getSelectedCount = function(component) {
266
+ if (component.displayMode === 'family') {
267
+ return `${component.selectedFamilies.length}/${component.families.length}`;
268
+ }
269
+ let selected = 0, total = 0;
270
+ component.families.forEach(family => {
271
+ const models = component.modelTree[family] || [];
272
+ total += models.length;
273
+ selected += (component.selectedModels[family] || []).length;
274
+ });
275
+ return `${selected}/${total}`;
276
+ };
277
+
278
+ /**
279
+ * Auto-select new families/models that haven't been configured
280
+ * @param {object} component - Dashboard component instance
281
+ */
282
+ window.DashboardFilters.autoSelectNew = function(component) {
283
+ // If no preferences saved, select all
284
+ if (component.selectedFamilies.length === 0 && Object.keys(component.selectedModels).length === 0) {
285
+ component.selectedFamilies = [...component.families];
286
+ component.families.forEach(family => {
287
+ component.selectedModels[family] = [...(component.modelTree[family] || [])];
288
+ });
289
+ window.DashboardFilters.savePreferences(component);
290
+ return;
291
+ }
292
+
293
+ // Add new families/models that appeared
294
+ component.families.forEach(family => {
295
+ if (!component.selectedFamilies.includes(family)) {
296
+ component.selectedFamilies.push(family);
297
+ }
298
+ if (!component.selectedModels[family]) {
299
+ component.selectedModels[family] = [];
300
+ }
301
+ (component.modelTree[family] || []).forEach(model => {
302
+ if (!component.selectedModels[family].includes(model)) {
303
+ component.selectedModels[family].push(model);
304
+ }
305
+ });
306
+ });
307
+ };
308
+
309
+ /**
310
+ * Auto-select top N models by usage (past 24 hours)
311
+ * @param {object} component - Dashboard component instance
312
+ * @param {number} n - Number of models to select (default: 5)
313
+ */
314
+ window.DashboardFilters.autoSelectTopN = function(component, n = 5) {
315
+ // Calculate usage for each model over past 24 hours
316
+ const usage = {};
317
+ const now = Date.now();
318
+ const dayAgo = now - 24 * 60 * 60 * 1000;
319
+
320
+ Object.entries(component.historyData).forEach(([iso, hourData]) => {
321
+ const timestamp = new Date(iso).getTime();
322
+ if (timestamp < dayAgo) return;
323
+
324
+ Object.entries(hourData).forEach(([family, familyData]) => {
325
+ if (typeof familyData === 'object' && family !== '_total') {
326
+ Object.entries(familyData).forEach(([model, count]) => {
327
+ if (model !== '_subtotal') {
328
+ const key = `${family}:${model}`;
329
+ usage[key] = (usage[key] || 0) + count;
330
+ }
331
+ });
332
+ }
333
+ });
334
+ });
335
+
336
+ // Sort by usage and take top N
337
+ const sorted = Object.entries(usage)
338
+ .sort((a, b) => b[1] - a[1])
339
+ .slice(0, n);
340
+
341
+ // Clear current selection
342
+ component.selectedFamilies = [];
343
+ component.selectedModels = {};
344
+
345
+ // Select top models and their families
346
+ sorted.forEach(([key, _]) => {
347
+ const [family, model] = key.split(':');
348
+ if (!component.selectedFamilies.includes(family)) {
349
+ component.selectedFamilies.push(family);
350
+ }
351
+ if (!component.selectedModels[family]) {
352
+ component.selectedModels[family] = [];
353
+ }
354
+ if (!component.selectedModels[family].includes(model)) {
355
+ component.selectedModels[family].push(model);
356
+ }
357
+ });
358
+
359
+ window.DashboardFilters.savePreferences(component);
360
+ // updateTrendChart uses debounce internally, call directly
361
+ component.updateTrendChart();
362
+ };
public/js/components/dashboard/stats.js ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Dashboard Stats Module
3
+ * 职责:根据 Alpine.store('data') 计算账号统计数据
4
+ *
5
+ * 调用时机:
6
+ * - dashboard 组件 init() 时
7
+ * - $store.data 更新时(通过 $watch 监听)
8
+ *
9
+ * 统计维度:
10
+ * - total: 启用账号总数(排除禁用账号)
11
+ * - active: 有可用配额的账号数
12
+ * - limited: 配额受限或失效的账号数
13
+ * - subscription: 按订阅级别分类(ultra/pro/free)
14
+ *
15
+ * @module DashboardStats
16
+ */
17
+ window.DashboardStats = window.DashboardStats || {};
18
+
19
+ /**
20
+ * 更新账号统计数据
21
+ *
22
+ * 统计逻辑:
23
+ * 1. 仅统计启用的账号(enabled !== false)
24
+ * 2. 检查账号下所有追踪模型的配额
25
+ * 3. 如果任一追踪模型配额 <= 5%,则标记为 limited (Rate Limited Cooldown)
26
+ * 4. 如果所有追踪模型配额 > 5%,则标记为 active
27
+ * 5. 状态非 'ok' 的账号归为 limited
28
+ *
29
+ * @param {object} component - Dashboard 组件实例(Alpine.js 上下文)
30
+ * @param {object} component.stats - 统计数据对象(会被修改)
31
+ * @param {number} component.stats.total - 启用账号总数
32
+ * @param {number} component.stats.active - 活跃账号数
33
+ * @param {number} component.stats.limited - 受限账号数
34
+ * @param {object} component.stats.subscription - 订阅级别分布
35
+ * @returns {void}
36
+ */
37
+ window.DashboardStats.updateStats = function(component) {
38
+ const accounts = Alpine.store('data').accounts;
39
+ let active = 0, limited = 0;
40
+
41
+ // Only count enabled accounts in statistics
42
+ const enabledAccounts = accounts.filter(acc => acc.enabled !== false);
43
+
44
+ enabledAccounts.forEach(acc => {
45
+ if (acc.status === 'ok') {
46
+ const limits = Object.entries(acc.limits || {});
47
+
48
+ if (limits.length === 0) {
49
+ // No limit data available, consider limited to be safe
50
+ limited++;
51
+ return;
52
+ }
53
+
54
+ // Check if ANY tracked model is rate limited (<= 5%)
55
+ // We consider all models in the limits object as "tracked"
56
+ const hasRateLimitedModel = limits.some(([_, l]) => {
57
+ // Treat null/undefined fraction as 0 (limited)
58
+ if (!l || l.remainingFraction === null || l.remainingFraction === undefined) return true;
59
+ return l.remainingFraction <= 0.05;
60
+ });
61
+
62
+ if (hasRateLimitedModel) {
63
+ limited++;
64
+ } else {
65
+ active++;
66
+ }
67
+ } else {
68
+ limited++;
69
+ }
70
+ });
71
+
72
+ // TOTAL shows only enabled accounts
73
+ // Disabled accounts are excluded from all statistics
74
+ component.stats.total = enabledAccounts.length;
75
+ component.stats.active = active;
76
+ component.stats.limited = limited;
77
+
78
+ // Calculate model usage for rate limit details
79
+ let totalLimitedModels = 0;
80
+ let totalTrackedModels = 0;
81
+
82
+ enabledAccounts.forEach(acc => {
83
+ const limits = Object.entries(acc.limits || {});
84
+ limits.forEach(([id, l]) => {
85
+ totalTrackedModels++;
86
+ if (!l || l.remainingFraction == null || l.remainingFraction <= 0.05) {
87
+ totalLimitedModels++;
88
+ }
89
+ });
90
+ });
91
+
92
+ component.stats.modelUsage = {
93
+ limited: totalLimitedModels,
94
+ total: totalTrackedModels
95
+ };
96
+
97
+ // Calculate subscription tier distribution
98
+ const subscription = { ultra: 0, pro: 0, free: 0 };
99
+ enabledAccounts.forEach(acc => {
100
+ const tier = acc.subscription?.tier || 'free';
101
+ if (tier === 'ultra') {
102
+ subscription.ultra++;
103
+ } else if (tier === 'pro') {
104
+ subscription.pro++;
105
+ } else {
106
+ subscription.free++;
107
+ }
108
+ });
109
+ component.stats.subscription = subscription;
110
+ };
public/js/components/logs-viewer.js ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Logs Viewer Component
3
+ * Registers itself to window.Components for Alpine.js to consume
4
+ */
5
+ window.Components = window.Components || {};
6
+
7
+ window.Components.logsViewer = () => ({
8
+ logs: [],
9
+ isAutoScroll: true,
10
+ eventSource: null,
11
+ searchQuery: '',
12
+ filters: {
13
+ INFO: true,
14
+ WARN: true,
15
+ ERROR: true,
16
+ SUCCESS: true,
17
+ DEBUG: false
18
+ },
19
+
20
+ get filteredLogs() {
21
+ const query = this.searchQuery.trim();
22
+ if (!query) {
23
+ return this.logs.filter(log => this.filters[log.level]);
24
+ }
25
+
26
+ // Try regex first, fallback to plain text search
27
+ let matcher;
28
+ try {
29
+ const regex = new RegExp(query, 'i');
30
+ matcher = (msg) => regex.test(msg);
31
+ } catch (e) {
32
+ // Invalid regex, fallback to case-insensitive string search
33
+ const lowerQuery = query.toLowerCase();
34
+ matcher = (msg) => msg.toLowerCase().includes(lowerQuery);
35
+ }
36
+
37
+ return this.logs.filter(log => {
38
+ // Level Filter
39
+ if (!this.filters[log.level]) return false;
40
+
41
+ // Search Filter
42
+ return matcher(log.message);
43
+ });
44
+ },
45
+
46
+ init() {
47
+ this.startLogStream();
48
+
49
+ // Sync DEBUG filter with debugLogging sub-toggle
50
+ const settings = Alpine.store('settings');
51
+ if (settings) {
52
+ this.filters.DEBUG = !!settings.debugLogging;
53
+ this.$watch('$store.settings.debugLogging', (val) => {
54
+ this.filters.DEBUG = !!val;
55
+ });
56
+ }
57
+
58
+ this.$watch('isAutoScroll', (val) => {
59
+ if (val) this.scrollToBottom();
60
+ });
61
+
62
+ // Watch filters to maintain auto-scroll if enabled
63
+ this.$watch('searchQuery', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) });
64
+ this.$watch('filters', () => { if(this.isAutoScroll) this.$nextTick(() => this.scrollToBottom()) });
65
+ },
66
+
67
+ startLogStream() {
68
+ if (this.eventSource) this.eventSource.close();
69
+
70
+ const password = Alpine.store('global').webuiPassword;
71
+ const url = password
72
+ ? `/api/logs/stream?history=true&password=${encodeURIComponent(password)}`
73
+ : '/api/logs/stream?history=true';
74
+
75
+ this.eventSource = new EventSource(url);
76
+ this.eventSource.onmessage = (event) => {
77
+ try {
78
+ const log = JSON.parse(event.data);
79
+ this.logs.push(log);
80
+
81
+ // Limit log buffer
82
+ const limit = Alpine.store('settings')?.logLimit || window.AppConstants.LIMITS.DEFAULT_LOG_LIMIT;
83
+ if (this.logs.length > limit) {
84
+ this.logs = this.logs.slice(-limit);
85
+ }
86
+
87
+ if (this.isAutoScroll) {
88
+ this.$nextTick(() => this.scrollToBottom());
89
+ }
90
+ } catch (e) {
91
+ if (window.UILogger) window.UILogger.debug('Log parse error:', e.message);
92
+ }
93
+ };
94
+
95
+ this.eventSource.onerror = () => {
96
+ if (window.UILogger) window.UILogger.debug('Log stream disconnected, reconnecting...');
97
+ setTimeout(() => this.startLogStream(), 3000);
98
+ };
99
+ },
100
+
101
+ scrollToBottom() {
102
+ const container = document.getElementById('logs-container');
103
+ if (container) container.scrollTop = container.scrollHeight;
104
+ },
105
+
106
+ clearLogs() {
107
+ this.logs = [];
108
+ },
109
+
110
+ exportLogs() {
111
+ if (this.logs.length === 0) return;
112
+
113
+ const shouldRedact = Alpine.store('settings')?.redactMode && window.Redact;
114
+ const lines = this.logs.map(log => {
115
+ const ts = new Date(log.timestamp).toISOString();
116
+ const message = shouldRedact ? window.Redact.logMessage(log.message) : log.message;
117
+ return `[${ts}] [${log.level}] ${message}`;
118
+ });
119
+
120
+ const text = lines.join('\n');
121
+ const blob = new Blob([text], { type: 'text/plain' });
122
+ const url = URL.createObjectURL(blob);
123
+ const a = document.createElement('a');
124
+ a.href = url;
125
+ a.download = `proxy-logs-${new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)}.txt`;
126
+ document.body.appendChild(a);
127
+ a.click();
128
+ document.body.removeChild(a);
129
+ URL.revokeObjectURL(url);
130
+ }
131
+ });
public/js/components/model-dropdown.js ADDED
@@ -0,0 +1,151 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Model Dropdown Component
3
+ * Reusable grouped model selector with search, clear, and keyboard navigation.
4
+ * Registers itself to window.Components for Alpine.js to consume.
5
+ *
6
+ * Usage (inside x-data="claudeConfig"):
7
+ * x-data="window.Components.modelDropdown('ANTHROPIC_MODEL', 'primaryModel', 'cyan')"
8
+ *
9
+ * @param {string} field - config.env key (e.g. 'ANTHROPIC_MODEL')
10
+ * @param {string} labelKey - i18n key for the label
11
+ * @param {string} accentColor - 'cyan' or 'purple' (maps to Tailwind border classes)
12
+ *
13
+ * Requires parent scope to provide: config, selectModel(), gemini1mSuffix
14
+ */
15
+ window.Components = window.Components || {};
16
+
17
+ window.Components.modelDropdown = (field, labelKey, accentColor) => ({
18
+ field,
19
+ labelKey,
20
+ accentColor,
21
+
22
+ open: false,
23
+ searchTerm: '',
24
+ highlightIndex: -1,
25
+
26
+ init() {
27
+ if (typeof this.selectModel !== 'function' || !this.config) {
28
+ console.error(`modelDropdown(${field}): must be nested inside a parent scope that provides config and selectModel()`);
29
+ }
30
+ },
31
+
32
+ get currentValue() {
33
+ return this.config?.env?.[this.field] || '';
34
+ },
35
+
36
+ get filteredModels() {
37
+ const models = this.$store.data.models || [];
38
+ if (!this.searchTerm) return models;
39
+ const term = this.searchTerm.toLowerCase();
40
+ return models.filter(m => m.toLowerCase().includes(term));
41
+ },
42
+
43
+ get groupedModels() {
44
+ const groups = [
45
+ { family: 'claude', label: this.$store.global.t('familyClaude'), items: [] },
46
+ { family: 'gemini', label: this.$store.global.t('familyGemini'), items: [] },
47
+ { family: 'other', label: this.$store.global.t('familyOther'), items: [] }
48
+ ];
49
+ for (const modelId of this.filteredModels) {
50
+ const fam = this.$store.data.getModelFamily(modelId);
51
+ const group = groups.find(g => g.family === fam) || groups[2];
52
+ group.items.push(modelId);
53
+ }
54
+ return groups.filter(g => g.items.length > 0);
55
+ },
56
+
57
+ get flatItems() {
58
+ const items = [];
59
+ if (this.currentValue) {
60
+ items.push({ type: 'clear' });
61
+ }
62
+ for (const group of this.groupedModels) {
63
+ for (const modelId of group.items) {
64
+ items.push({ type: 'model', id: modelId });
65
+ }
66
+ }
67
+ return items;
68
+ },
69
+
70
+ get focusBorderClass() {
71
+ return this.accentColor === 'purple'
72
+ ? 'focus:!border-neon-purple'
73
+ : 'focus:!border-neon-cyan';
74
+ },
75
+
76
+ openDropdown() {
77
+ this.open = true;
78
+ this.searchTerm = '';
79
+ this.highlightIndex = -1;
80
+ },
81
+
82
+ closeDropdown() {
83
+ this.open = false;
84
+ this.searchTerm = '';
85
+ this.highlightIndex = -1;
86
+ },
87
+
88
+ choose(modelId) {
89
+ this.selectModel(this.field, modelId);
90
+ this.closeDropdown();
91
+ },
92
+
93
+ clearField() {
94
+ if (this.config?.env) {
95
+ delete this.config.env[this.field];
96
+ }
97
+ this.closeDropdown();
98
+ },
99
+
100
+ isSelected(modelId) {
101
+ const val = this.currentValue;
102
+ return val === modelId || val === modelId + '[1m]';
103
+ },
104
+
105
+ onKeydown(event) {
106
+ if (!this.open) {
107
+ if (event.key === 'ArrowDown' || event.key === 'Enter') {
108
+ event.preventDefault();
109
+ this.openDropdown();
110
+ }
111
+ return;
112
+ }
113
+
114
+ const items = this.flatItems;
115
+ if (event.key === 'ArrowDown') {
116
+ event.preventDefault();
117
+ this.highlightIndex = Math.min(this.highlightIndex + 1, items.length - 1);
118
+ this.scrollToHighlighted();
119
+ } else if (event.key === 'ArrowUp') {
120
+ event.preventDefault();
121
+ this.highlightIndex = Math.max(this.highlightIndex - 1, 0);
122
+ this.scrollToHighlighted();
123
+ } else if (event.key === 'Enter') {
124
+ event.preventDefault();
125
+ if (this.highlightIndex >= 0 && this.highlightIndex < items.length) {
126
+ const item = items[this.highlightIndex];
127
+ if (item.type === 'clear') {
128
+ this.clearField();
129
+ } else {
130
+ this.choose(item.id);
131
+ }
132
+ }
133
+ } else if (event.key === 'Escape') {
134
+ event.preventDefault();
135
+ this.closeDropdown();
136
+ }
137
+ },
138
+
139
+ scrollToHighlighted() {
140
+ this.$nextTick(() => {
141
+ const list = this.$refs.dropdownList;
142
+ if (!list) return;
143
+ const el = list.querySelector('[data-highlight="true"]');
144
+ if (el) el.scrollIntoView({ block: 'nearest' });
145
+ });
146
+ },
147
+
148
+ getFlatIndex(modelId) {
149
+ return this.flatItems.findIndex(i => i.type === 'model' && i.id === modelId);
150
+ }
151
+ });
public/js/components/models.js ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Models Component
3
+ * Displays model quota/status list
4
+ * Registers itself to window.Components for Alpine.js to consume
5
+ */
6
+ window.Components = window.Components || {};
7
+
8
+ window.Components.models = () => ({
9
+ // Color palette for per-account threshold markers
10
+ thresholdColors: [
11
+ { bg: '#eab308', shadow: 'rgba(234,179,8,0.5)' }, // yellow
12
+ { bg: '#06b6d4', shadow: 'rgba(6,182,212,0.5)' }, // cyan
13
+ { bg: '#a855f7', shadow: 'rgba(168,85,247,0.5)' }, // purple
14
+ { bg: '#22c55e', shadow: 'rgba(34,197,94,0.5)' }, // green
15
+ { bg: '#ef4444', shadow: 'rgba(239,68,68,0.5)' }, // red
16
+ { bg: '#f97316', shadow: 'rgba(249,115,22,0.5)' }, // orange
17
+ { bg: '#ec4899', shadow: 'rgba(236,72,153,0.5)' }, // pink
18
+ { bg: '#8b5cf6', shadow: 'rgba(139,92,246,0.5)' }, // violet
19
+ ],
20
+
21
+ getThresholdColor(index) {
22
+ return this.thresholdColors[index % this.thresholdColors.length];
23
+ },
24
+
25
+ // Drag state for threshold markers
26
+ dragging: {
27
+ active: false,
28
+ email: null,
29
+ modelId: null,
30
+ barRect: null,
31
+ currentPct: 0,
32
+ originalPct: 0
33
+ },
34
+
35
+ // Tracks which model rows have their per-account breakdown expanded beyond the cap
36
+ expandedModels: new Set(),
37
+
38
+ isExpanded(modelId) {
39
+ return this.expandedModels.has(modelId);
40
+ },
41
+
42
+ toggleExpanded(modelId) {
43
+ if (this.expandedModels.has(modelId)) {
44
+ this.expandedModels.delete(modelId);
45
+ } else {
46
+ this.expandedModels.add(modelId);
47
+ }
48
+ // Force Alpine reactivity
49
+ this.expandedModels = new Set(this.expandedModels);
50
+ },
51
+
52
+ /**
53
+ * Get visible account rows for a model's breakdown, respecting the cap
54
+ */
55
+ getVisibleAccounts(row) {
56
+ const all = row.quotaInfo || [];
57
+ if (Alpine.store('settings').showAllAccounts || this.isExpanded(row.modelId)) {
58
+ return all;
59
+ }
60
+ const limit = window.AppConstants.LIMITS.ACCOUNT_BREAKDOWN_LIMIT;
61
+ return all.slice(0, limit);
62
+ },
63
+
64
+ /**
65
+ * Get the number of hidden accounts for a model row
66
+ */
67
+ getHiddenCount(row) {
68
+ const all = row.quotaInfo || [];
69
+ const limit = window.AppConstants.LIMITS.ACCOUNT_BREAKDOWN_LIMIT;
70
+ if (Alpine.store('settings').showAllAccounts || this.isExpanded(row.modelId)) {
71
+ return 0;
72
+ }
73
+ return Math.max(0, all.length - limit);
74
+ },
75
+
76
+ // Model editing state (from main)
77
+ editingModelId: null,
78
+ newMapping: '',
79
+
80
+ isEditing(modelId) {
81
+ return this.editingModelId === modelId;
82
+ },
83
+
84
+ startEditing(modelId) {
85
+ this.editingModelId = modelId;
86
+ },
87
+
88
+ stopEditing() {
89
+ this.editingModelId = null;
90
+ },
91
+
92
+ /**
93
+ * Start dragging a threshold marker
94
+ */
95
+ startDrag(event, q, row) {
96
+ // Find the progress bar element (closest .relative container)
97
+ const markerEl = event.currentTarget;
98
+ const barContainer = markerEl.parentElement;
99
+ const barRect = barContainer.getBoundingClientRect();
100
+
101
+ this.dragging = {
102
+ active: true,
103
+ email: q.fullEmail,
104
+ modelId: row.modelId,
105
+ barRect,
106
+ currentPct: q.thresholdPct,
107
+ originalPct: q.thresholdPct
108
+ };
109
+
110
+ // Prevent text selection while dragging
111
+ document.body.classList.add('select-none');
112
+
113
+ // Bind document-level listeners for smooth dragging outside the marker
114
+ this._onDrag = (e) => this.onDrag(e);
115
+ this._endDrag = () => this.endDrag();
116
+ document.addEventListener('mousemove', this._onDrag);
117
+ document.addEventListener('mouseup', this._endDrag);
118
+ document.addEventListener('touchmove', this._onDrag, { passive: false });
119
+ document.addEventListener('touchend', this._endDrag);
120
+ },
121
+
122
+ /**
123
+ * Handle drag movement — compute percentage from mouse position
124
+ */
125
+ onDrag(event) {
126
+ if (!this.dragging.active) return;
127
+ event.preventDefault();
128
+
129
+ const clientX = event.touches ? event.touches[0].clientX : event.clientX;
130
+ const { left, width } = this.dragging.barRect;
131
+ let pct = Math.round((clientX - left) / width * 100);
132
+ pct = Math.max(0, Math.min(99, pct));
133
+
134
+ this.dragging.currentPct = pct;
135
+ },
136
+
137
+ /**
138
+ * End drag — save the new threshold value
139
+ */
140
+ endDrag() {
141
+ if (!this.dragging.active) return;
142
+
143
+ // Clean up listeners
144
+ document.removeEventListener('mousemove', this._onDrag);
145
+ document.removeEventListener('mouseup', this._endDrag);
146
+ document.removeEventListener('touchmove', this._onDrag);
147
+ document.removeEventListener('touchend', this._endDrag);
148
+ document.body.classList.remove('select-none');
149
+
150
+ const { email, modelId, currentPct, originalPct } = this.dragging;
151
+
152
+ // Only save if value actually changed
153
+ if (currentPct !== originalPct) {
154
+ // Optimistic in-place update: mutate existing quotaInfo entries directly
155
+ // to avoid full DOM rebuild from computeQuotaRows()
156
+ const dataStore = Alpine.store('data');
157
+ const account = dataStore.accounts.find(a => a.email === email);
158
+ if (account) {
159
+ if (!account.modelQuotaThresholds) account.modelQuotaThresholds = {};
160
+ if (currentPct === 0) {
161
+ delete account.modelQuotaThresholds[modelId];
162
+ } else {
163
+ account.modelQuotaThresholds[modelId] = currentPct / 100;
164
+ }
165
+ }
166
+ // Patch quotaRows in-place so Alpine updates without tearing down DOM
167
+ const rows = dataStore.quotaRows || [];
168
+ for (const row of rows) {
169
+ if (row.modelId !== modelId) continue;
170
+ for (const q of row.quotaInfo) {
171
+ if (q.fullEmail !== email) continue;
172
+ q.thresholdPct = currentPct;
173
+ }
174
+ // Recompute row-level threshold stats
175
+ const activePcts = row.quotaInfo.map(q => q.thresholdPct).filter(t => t > 0);
176
+ row.effectiveThresholdPct = activePcts.length > 0 ? Math.max(...activePcts) : 0;
177
+ row.hasVariedThresholds = new Set(activePcts).size > 1;
178
+ }
179
+ this.dragging.active = false;
180
+ this.saveModelThreshold(email, modelId, currentPct);
181
+ } else {
182
+ this.dragging.active = false;
183
+ }
184
+ },
185
+
186
+ /**
187
+ * Save a per-model threshold for an account via PATCH
188
+ */
189
+ async saveModelThreshold(email, modelId, pct) {
190
+ const store = Alpine.store('global');
191
+ const dataStore = Alpine.store('data');
192
+
193
+ const account = dataStore.accounts.find(a => a.email === email);
194
+ if (!account) return;
195
+
196
+ // Snapshot for rollback on failure
197
+ const previousModelThresholds = account.modelQuotaThresholds ? { ...account.modelQuotaThresholds } : {};
198
+
199
+ // Build full modelQuotaThresholds for API (full replacement, not merge)
200
+ const existingModelThresholds = { ...(account.modelQuotaThresholds || {}) };
201
+
202
+ // Preserve the account-level quotaThreshold
203
+ const quotaThreshold = account.quotaThreshold !== undefined ? account.quotaThreshold : null;
204
+
205
+ try {
206
+ const { response, newPassword } = await window.utils.request(
207
+ `/api/accounts/${encodeURIComponent(email)}`,
208
+ {
209
+ method: 'PATCH',
210
+ headers: { 'Content-Type': 'application/json' },
211
+ body: JSON.stringify({ quotaThreshold, modelQuotaThresholds: existingModelThresholds })
212
+ },
213
+ store.webuiPassword
214
+ );
215
+ if (newPassword) store.webuiPassword = newPassword;
216
+
217
+ const data = await response.json();
218
+ if (data.status === 'ok') {
219
+ const label = pct === 0 ? 'removed' : pct + '%';
220
+ store.showToast(`${email.split('@')[0]} ${modelId} threshold: ${label}`, 'success');
221
+ // Skip fetchData() — optimistic update is already applied,
222
+ // next polling cycle will sync server state
223
+ } else {
224
+ throw new Error(data.error || 'Failed to save threshold');
225
+ }
226
+ } catch (e) {
227
+ // Revert optimistic update on failure
228
+ account.modelQuotaThresholds = previousModelThresholds;
229
+ dataStore.computeQuotaRows();
230
+ store.showToast('Failed to save threshold: ' + e.message, 'error');
231
+ }
232
+ },
233
+
234
+ /**
235
+ * Check if a specific marker is currently being dragged
236
+ */
237
+ isDragging(q, row) {
238
+ return this.dragging.active && this.dragging.email === q.fullEmail && this.dragging.modelId === row.modelId;
239
+ },
240
+
241
+ /**
242
+ * Get the display percentage for a marker (live during drag, stored otherwise)
243
+ */
244
+ getMarkerPct(q, row) {
245
+ if (this.isDragging(q, row)) return this.dragging.currentPct;
246
+ return q.thresholdPct;
247
+ },
248
+
249
+ /**
250
+ * Compute pixel offset for overlapping markers so stacked ones fan out.
251
+ * Markers within 2% of each other are considered overlapping.
252
+ * Returns a CSS pixel offset string (e.g., '6px' or '-6px').
253
+ */
254
+ getMarkerOffset(q, row, qIdx) {
255
+ const pct = this.getMarkerPct(q, row);
256
+ const visible = row.quotaInfo.filter(item => item.thresholdPct > 0 || this.isDragging(item, row));
257
+ // Find all markers within 2% of this one
258
+ const cluster = [];
259
+ visible.forEach((item, idx) => {
260
+ const itemPct = this.getMarkerPct(item, row);
261
+ if (Math.abs(itemPct - pct) <= 2) {
262
+ cluster.push({ item, idx });
263
+ }
264
+ });
265
+ if (cluster.length <= 1) return '0px';
266
+ // Find position of this marker within its cluster
267
+ const posInCluster = cluster.findIndex(c => c.item.fullEmail === q.fullEmail);
268
+ // Spread markers 10px apart, centered on the base position
269
+ const spread = 10;
270
+ const totalWidth = (cluster.length - 1) * spread;
271
+ return (posInCluster * spread - totalWidth / 2) + 'px';
272
+ },
273
+
274
+ init() {
275
+ // Ensure data is fetched when this tab becomes active (skip initial trigger)
276
+ this.$watch('$store.global.activeTab', (val, oldVal) => {
277
+ if (val === 'models' && oldVal !== undefined) {
278
+ // Trigger recompute to ensure filters are applied
279
+ this.$nextTick(() => {
280
+ Alpine.store('data').computeQuotaRows();
281
+ });
282
+ }
283
+ });
284
+
285
+ // Initial compute if already on models tab
286
+ if (this.$store.global.activeTab === 'models') {
287
+ this.$nextTick(() => {
288
+ Alpine.store('data').computeQuotaRows();
289
+ });
290
+ }
291
+ },
292
+
293
+ /**
294
+ * Update model configuration (delegates to shared utility)
295
+ * @param {string} modelId - The model ID to update
296
+ * @param {object} configUpdates - Configuration updates (pinned, hidden)
297
+ */
298
+ async updateModelConfig(modelId, configUpdates) {
299
+ return window.ModelConfigUtils.updateModelConfig(modelId, configUpdates);
300
+ }
301
+ });
public/js/components/server-config.js ADDED
@@ -0,0 +1,1203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Server Config Component
3
+ * Registers itself to window.Components for Alpine.js to consume
4
+ */
5
+ window.Components = window.Components || {};
6
+
7
+ window.Components.serverConfig = () => ({
8
+ serverConfig: {},
9
+ loading: false,
10
+ advancedExpanded: false,
11
+ debounceTimers: {}, // Store debounce timers for each config field
12
+
13
+ // Server presets state
14
+ serverPresets: [],
15
+ selectedServerPreset: '',
16
+ loadingPreset: false,
17
+ savingServerPreset: false,
18
+ deletingServerPreset: false,
19
+ newServerPresetName: '',
20
+ newServerPresetDescription: '',
21
+ editingPresetMode: false,
22
+ editingPresetOriginalName: '',
23
+ presetPreviewExpanded: false,
24
+ editingPresetConfig: false,
25
+ editingConfigDraft: { accountSelection: { strategy: 'hybrid' } },
26
+ editingConfigErrors: {},
27
+ savingPresetConfig: false,
28
+ configEditMode: 'ui',
29
+ editingJsonText: '',
30
+ jsonParseError: null,
31
+
32
+ init() {
33
+ // Initial fetch if this is the active sub-tab
34
+ if (this.$store.global.settingsTab === 'server') {
35
+ this.fetchServerConfig();
36
+ this.fetchServerPresets();
37
+ }
38
+
39
+ // Watch settings sub-tab (skip initial trigger)
40
+ this.$watch('$store.global.settingsTab', (tab, oldTab) => {
41
+ if (tab === 'server' && oldTab !== undefined) {
42
+ this.fetchServerConfig();
43
+ this.fetchServerPresets();
44
+ }
45
+ });
46
+
47
+ // Cancel config editing when switching presets
48
+ this.$watch('selectedServerPreset', () => {
49
+ if (this.editingPresetConfig) {
50
+ this.cancelPresetConfigEdit();
51
+ }
52
+ });
53
+ },
54
+
55
+ async fetchServerConfig() {
56
+ const password = Alpine.store('global').webuiPassword;
57
+ try {
58
+ const { response, newPassword } = await window.utils.request('/api/config', {}, password);
59
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
60
+
61
+ if (!response.ok) throw new Error('Failed to fetch config');
62
+ const data = await response.json();
63
+ this.serverConfig = data.config || {};
64
+ } catch (e) {
65
+ console.error('Failed to fetch server config:', e);
66
+ }
67
+ },
68
+
69
+
70
+
71
+ // Password management
72
+ passwordDialog: {
73
+ show: false,
74
+ oldPassword: '',
75
+ newPassword: '',
76
+ confirmPassword: ''
77
+ },
78
+
79
+ showPasswordDialog() {
80
+ this.passwordDialog = {
81
+ show: true,
82
+ oldPassword: '',
83
+ newPassword: '',
84
+ confirmPassword: ''
85
+ };
86
+ },
87
+
88
+ hidePasswordDialog() {
89
+ this.passwordDialog = {
90
+ show: false,
91
+ oldPassword: '',
92
+ newPassword: '',
93
+ confirmPassword: ''
94
+ };
95
+ },
96
+
97
+ async changePassword() {
98
+ const store = Alpine.store('global');
99
+ const { oldPassword, newPassword, confirmPassword } = this.passwordDialog;
100
+
101
+ if (newPassword !== confirmPassword) {
102
+ store.showToast(store.t('passwordsNotMatch'), 'error');
103
+ return;
104
+ }
105
+ if (newPassword.length < 6) {
106
+ store.showToast(store.t('passwordTooShort'), 'error');
107
+ return;
108
+ }
109
+
110
+ try {
111
+ const { response } = await window.utils.request('/api/config/password', {
112
+ method: 'POST',
113
+ headers: { 'Content-Type': 'application/json' },
114
+ body: JSON.stringify({ oldPassword, newPassword })
115
+ }, store.webuiPassword);
116
+
117
+ if (!response.ok) {
118
+ const data = await response.json();
119
+ throw new Error(data.error || store.t('failedToChangePassword'));
120
+ }
121
+
122
+ // Update stored password
123
+ store.webuiPassword = newPassword;
124
+ store.showToast(store.t('passwordChangedSuccess'), 'success');
125
+ this.hidePasswordDialog();
126
+ } catch (e) {
127
+ store.showToast(store.t('failedToChangePassword') + ': ' + e.message, 'error');
128
+ }
129
+ },
130
+
131
+ // Toggle Developer Mode with instant save
132
+ async toggleDevMode(enabled) {
133
+ const store = Alpine.store('global');
134
+
135
+ // Optimistic update
136
+ const previousDevMode = this.serverConfig.devMode;
137
+ const previousDebug = this.serverConfig.debug;
138
+ this.serverConfig.devMode = enabled;
139
+ this.serverConfig.debug = enabled;
140
+
141
+ try {
142
+ const { response, newPassword } = await window.utils.request('/api/config', {
143
+ method: 'POST',
144
+ headers: { 'Content-Type': 'application/json' },
145
+ body: JSON.stringify({ devMode: enabled })
146
+ }, store.webuiPassword);
147
+
148
+ if (newPassword) store.webuiPassword = newPassword;
149
+
150
+ const data = await response.json();
151
+ if (data.status === 'ok') {
152
+ const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
153
+ store.showToast(store.t('devModeToggled', { status }), 'success');
154
+ // Update data store
155
+ Alpine.store('data').devMode = enabled;
156
+ await this.fetchServerConfig(); // Confirm server state
157
+ } else {
158
+ throw new Error(data.error || store.t('failedToUpdateDevMode'));
159
+ }
160
+ } catch (e) {
161
+ // Rollback on error
162
+ this.serverConfig.devMode = previousDevMode;
163
+ this.serverConfig.debug = previousDebug;
164
+ store.showToast(store.t('failedToUpdateDevMode') + ': ' + e.message, 'error');
165
+ }
166
+ },
167
+
168
+ // Toggle Token Cache with instant save
169
+ async toggleTokenCache(enabled) {
170
+ const store = Alpine.store('global');
171
+
172
+ // Optimistic update
173
+ const previousValue = this.serverConfig.persistTokenCache;
174
+ this.serverConfig.persistTokenCache = enabled;
175
+
176
+ try {
177
+ const { response, newPassword } = await window.utils.request('/api/config', {
178
+ method: 'POST',
179
+ headers: { 'Content-Type': 'application/json' },
180
+ body: JSON.stringify({ persistTokenCache: enabled })
181
+ }, store.webuiPassword);
182
+
183
+ if (newPassword) store.webuiPassword = newPassword;
184
+
185
+ const data = await response.json();
186
+ if (data.status === 'ok') {
187
+ const status = enabled ? store.t('enabledStatus') : store.t('disabledStatus');
188
+ store.showToast(store.t('tokenCacheToggled', { status }), 'success');
189
+ await this.fetchServerConfig(); // Confirm server state
190
+ } else {
191
+ throw new Error(data.error || store.t('failedToUpdateTokenCache'));
192
+ }
193
+ } catch (e) {
194
+ // Rollback on error
195
+ this.serverConfig.persistTokenCache = previousValue;
196
+ store.showToast(store.t('failedToUpdateTokenCache') + ': ' + e.message, 'error');
197
+ }
198
+ },
199
+
200
+ // Generic debounced save method for numeric configs with validation
201
+ async saveConfigField(fieldName, value, displayName, validator = null) {
202
+ const store = Alpine.store('global');
203
+
204
+ // Validate input if validator provided
205
+ if (validator) {
206
+ const validation = window.Validators.validate(value, validator, true);
207
+ if (!validation.isValid) {
208
+ // Rollback to clamped value
209
+ this.serverConfig[fieldName] = validation.value;
210
+ return;
211
+ }
212
+ value = validation.value;
213
+ } else {
214
+ value = parseInt(value);
215
+ }
216
+
217
+ // Clear existing timer for this field
218
+ if (this.debounceTimers[fieldName]) {
219
+ clearTimeout(this.debounceTimers[fieldName]);
220
+ }
221
+
222
+ // Optimistic update
223
+ const previousValue = this.serverConfig[fieldName];
224
+ this.serverConfig[fieldName] = value;
225
+
226
+ // Set new timer
227
+ this.debounceTimers[fieldName] = setTimeout(async () => {
228
+ try {
229
+ const payload = {};
230
+ payload[fieldName] = value;
231
+
232
+ const { response, newPassword } = await window.utils.request('/api/config', {
233
+ method: 'POST',
234
+ headers: { 'Content-Type': 'application/json' },
235
+ body: JSON.stringify(payload)
236
+ }, store.webuiPassword);
237
+
238
+ if (newPassword) store.webuiPassword = newPassword;
239
+
240
+ const data = await response.json();
241
+ if (data.status === 'ok') {
242
+ store.showToast(store.t('fieldUpdated', { displayName, value }), 'success');
243
+ await this.fetchServerConfig(); // Confirm server state
244
+ } else {
245
+ throw new Error(data.error || store.t('failedToUpdateField', { displayName }));
246
+ }
247
+ } catch (e) {
248
+ // Rollback on error
249
+ this.serverConfig[fieldName] = previousValue;
250
+ store.showToast(store.t('failedToUpdateField', { displayName }) + ': ' + e.message, 'error');
251
+ }
252
+ }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
253
+ },
254
+
255
+ // Individual toggle methods for each Advanced Tuning field with validation
256
+ toggleMaxRetries(value) {
257
+ const { MAX_RETRIES_MIN, MAX_RETRIES_MAX } = window.AppConstants.VALIDATION;
258
+ this.saveConfigField('maxRetries', value, 'Max Retries',
259
+ (v) => window.Validators.validateRange(v, MAX_RETRIES_MIN, MAX_RETRIES_MAX, 'Max Retries'));
260
+ },
261
+
262
+ toggleRetryBaseMs(value) {
263
+ const { RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX } = window.AppConstants.VALIDATION;
264
+ this.saveConfigField('retryBaseMs', value, 'Retry Base Delay',
265
+ (v) => window.Validators.validateRange(v, RETRY_BASE_MS_MIN, RETRY_BASE_MS_MAX, 'Retry Base Delay'));
266
+ },
267
+
268
+ toggleRetryMaxMs(value) {
269
+ const { RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX } = window.AppConstants.VALIDATION;
270
+ this.saveConfigField('retryMaxMs', value, 'Retry Max Delay',
271
+ (v) => window.Validators.validateRange(v, RETRY_MAX_MS_MIN, RETRY_MAX_MS_MAX, 'Retry Max Delay'));
272
+ },
273
+
274
+ toggleDefaultCooldownMs(value) {
275
+ const { DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX } = window.AppConstants.VALIDATION;
276
+ this.saveConfigField('defaultCooldownMs', value, 'Default Cooldown',
277
+ (v) => window.Validators.validateTimeout(v, DEFAULT_COOLDOWN_MIN, DEFAULT_COOLDOWN_MAX));
278
+ },
279
+
280
+ toggleMaxWaitBeforeErrorMs(value) {
281
+ const { MAX_WAIT_MIN, MAX_WAIT_MAX } = window.AppConstants.VALIDATION;
282
+ this.saveConfigField('maxWaitBeforeErrorMs', value, 'Max Wait Threshold',
283
+ (v) => window.Validators.validateTimeout(v, MAX_WAIT_MIN, MAX_WAIT_MAX));
284
+ },
285
+
286
+ toggleGlobalQuotaThreshold(value) {
287
+ const { GLOBAL_QUOTA_THRESHOLD_MIN, GLOBAL_QUOTA_THRESHOLD_MAX } = window.AppConstants.VALIDATION;
288
+ const store = Alpine.store('global');
289
+ const pct = parseInt(value);
290
+ if (isNaN(pct) || pct < GLOBAL_QUOTA_THRESHOLD_MIN || pct > GLOBAL_QUOTA_THRESHOLD_MAX) return;
291
+
292
+ // Store as percentage in UI, convert to fraction for backend
293
+ const fraction = pct / 100;
294
+
295
+ if (this.debounceTimers['globalQuotaThreshold']) {
296
+ clearTimeout(this.debounceTimers['globalQuotaThreshold']);
297
+ }
298
+
299
+ const previousValue = this.serverConfig.globalQuotaThreshold;
300
+ this.serverConfig.globalQuotaThreshold = fraction;
301
+
302
+ this.debounceTimers['globalQuotaThreshold'] = setTimeout(async () => {
303
+ try {
304
+ const { response, newPassword } = await window.utils.request('/api/config', {
305
+ method: 'POST',
306
+ headers: { 'Content-Type': 'application/json' },
307
+ body: JSON.stringify({ globalQuotaThreshold: fraction })
308
+ }, store.webuiPassword);
309
+
310
+ if (newPassword) store.webuiPassword = newPassword;
311
+
312
+ const data = await response.json();
313
+ if (data.status === 'ok') {
314
+ store.showToast(store.t('fieldUpdated', { displayName: 'Minimum Quota Level', value: pct + '%' }), 'success');
315
+ await this.fetchServerConfig();
316
+ } else {
317
+ throw new Error(data.error || store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }));
318
+ }
319
+ } catch (e) {
320
+ this.serverConfig.globalQuotaThreshold = previousValue;
321
+ store.showToast(store.t('failedToUpdateField', { displayName: 'Minimum Quota Level' }) + ': ' + e.message, 'error');
322
+ }
323
+ }, window.AppConstants.INTERVALS.CONFIG_DEBOUNCE);
324
+ },
325
+
326
+ async toggleRequestThrottling(enabled) {
327
+ const store = Alpine.store('global');
328
+ const previousValue = this.serverConfig.requestThrottlingEnabled;
329
+ this.serverConfig.requestThrottlingEnabled = enabled;
330
+
331
+ try {
332
+ const { response, newPassword } = await window.utils.request('/api/config', {
333
+ method: 'POST',
334
+ headers: { 'Content-Type': 'application/json' },
335
+ body: JSON.stringify({ requestThrottlingEnabled: enabled })
336
+ }, store.webuiPassword);
337
+
338
+ if (newPassword) store.webuiPassword = newPassword;
339
+
340
+ const data = await response.json();
341
+ if (data.status === 'ok') {
342
+ store.showToast(`Request Throttling ${enabled ? 'enabled' : 'disabled'}`, 'success');
343
+ } else {
344
+ throw new Error(data.error || 'Failed to update');
345
+ }
346
+ } catch (e) {
347
+ this.serverConfig.requestThrottlingEnabled = previousValue;
348
+ store.showToast('Failed to update Request Throttling: ' + e.message, 'error');
349
+ }
350
+ },
351
+
352
+ toggleRequestDelayMs(value) {
353
+ const { REQUEST_DELAY_MIN, REQUEST_DELAY_MAX } = window.AppConstants.VALIDATION;
354
+ this.saveConfigField('requestDelayMs', value, 'Request Delay',
355
+ (v) => window.Validators.validateRange(v, REQUEST_DELAY_MIN, REQUEST_DELAY_MAX, 'Request Delay'));
356
+ },
357
+
358
+ toggleMaxAccounts(value) {
359
+ const { MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX } = window.AppConstants.VALIDATION;
360
+ this.saveConfigField('maxAccounts', value, 'Max Accounts',
361
+ (v) => window.Validators.validateRange(v, MAX_ACCOUNTS_MIN, MAX_ACCOUNTS_MAX, 'Max Accounts'));
362
+ },
363
+
364
+ toggleRateLimitDedupWindowMs(value) {
365
+ const { RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX } = window.AppConstants.VALIDATION;
366
+ this.saveConfigField('rateLimitDedupWindowMs', value, 'Rate Limit Dedup Window',
367
+ (v) => window.Validators.validateTimeout(v, RATE_LIMIT_DEDUP_MIN, RATE_LIMIT_DEDUP_MAX));
368
+ },
369
+
370
+ toggleMaxConsecutiveFailures(value) {
371
+ const { MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX } = window.AppConstants.VALIDATION;
372
+ this.saveConfigField('maxConsecutiveFailures', value, 'Max Consecutive Failures',
373
+ (v) => window.Validators.validateRange(v, MAX_CONSECUTIVE_FAILURES_MIN, MAX_CONSECUTIVE_FAILURES_MAX, 'Max Consecutive Failures'));
374
+ },
375
+
376
+ toggleExtendedCooldownMs(value) {
377
+ const { EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX } = window.AppConstants.VALIDATION;
378
+ this.saveConfigField('extendedCooldownMs', value, 'Extended Cooldown',
379
+ (v) => window.Validators.validateTimeout(v, EXTENDED_COOLDOWN_MIN, EXTENDED_COOLDOWN_MAX));
380
+ },
381
+
382
+ toggleMaxCapacityRetries(value) {
383
+ const { MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX } = window.AppConstants.VALIDATION;
384
+ this.saveConfigField('maxCapacityRetries', value, 'Max Capacity Retries',
385
+ (v) => window.Validators.validateRange(v, MAX_CAPACITY_RETRIES_MIN, MAX_CAPACITY_RETRIES_MAX, 'Max Capacity Retries'));
386
+ },
387
+
388
+ // Toggle Account Selection Strategy
389
+ async toggleStrategy(strategy) {
390
+ const store = Alpine.store('global');
391
+ const validStrategies = ['sticky', 'round-robin', 'hybrid'];
392
+
393
+ if (!validStrategies.includes(strategy)) {
394
+ store.showToast(store.t('invalidStrategy'), 'error');
395
+ return;
396
+ }
397
+
398
+ // Optimistic update
399
+ const previousValue = this.serverConfig.accountSelection?.strategy || 'hybrid';
400
+ if (!this.serverConfig.accountSelection) {
401
+ this.serverConfig.accountSelection = {};
402
+ }
403
+ this.serverConfig.accountSelection.strategy = strategy;
404
+
405
+ try {
406
+ const { response, newPassword } = await window.utils.request('/api/config', {
407
+ method: 'POST',
408
+ headers: { 'Content-Type': 'application/json' },
409
+ body: JSON.stringify({ accountSelection: { strategy } })
410
+ }, store.webuiPassword);
411
+
412
+ if (newPassword) store.webuiPassword = newPassword;
413
+
414
+ const data = await response.json();
415
+ if (data.status === 'ok') {
416
+ const strategyLabel = this.getStrategyLabel(strategy);
417
+ store.showToast(store.t('strategyUpdated', { strategy: strategyLabel }), 'success');
418
+ await this.fetchServerConfig(); // Confirm server state
419
+ } else {
420
+ throw new Error(data.error || store.t('failedToUpdateStrategy'));
421
+ }
422
+ } catch (e) {
423
+ // Rollback on error
424
+ if (!this.serverConfig.accountSelection) {
425
+ this.serverConfig.accountSelection = {};
426
+ }
427
+ this.serverConfig.accountSelection.strategy = previousValue;
428
+ store.showToast(store.t('failedToUpdateStrategy') + ': ' + e.message, 'error');
429
+ }
430
+ },
431
+
432
+ // Get display label for a strategy
433
+ getStrategyLabel(strategy) {
434
+ const store = Alpine.store('global');
435
+ const labels = {
436
+ 'sticky': store.t('strategyStickyLabel'),
437
+ 'round-robin': store.t('strategyRoundRobinLabel'),
438
+ 'hybrid': store.t('strategyHybridLabel')
439
+ };
440
+ return labels[strategy] || strategy;
441
+ },
442
+
443
+ // Get description for current strategy
444
+ currentStrategyDescription() {
445
+ const store = Alpine.store('global');
446
+ const strategy = this.serverConfig.accountSelection?.strategy || 'hybrid';
447
+ const descriptions = {
448
+ 'sticky': store.t('strategyStickyDesc'),
449
+ 'round-robin': store.t('strategyRoundRobinDesc'),
450
+ 'hybrid': store.t('strategyHybridDesc')
451
+ };
452
+ return descriptions[strategy] || '';
453
+ },
454
+
455
+ // ==========================================
456
+ // Server Configuration Presets
457
+ // ==========================================
458
+
459
+ async fetchServerPresets() {
460
+ const password = Alpine.store('global').webuiPassword;
461
+ try {
462
+ const { response, newPassword } = await window.utils.request('/api/server/presets', {}, password);
463
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
464
+
465
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
466
+ const data = await response.json();
467
+ if (data.status === 'ok') {
468
+ this.serverPresets = data.presets || [];
469
+ if (this.serverPresets.length > 0 && !this.selectedServerPreset) {
470
+ this.selectedServerPreset = this.serverPresets[0].name;
471
+ }
472
+ }
473
+ } catch (e) {
474
+ console.error('Failed to fetch server presets:', e);
475
+ }
476
+ },
477
+
478
+ /**
479
+ * Load a server preset — applies all config values via POST /api/config
480
+ */
481
+ async loadServerPreset(name) {
482
+ const preset = this.serverPresets.find(p => p.name === name);
483
+ if (!preset) return;
484
+
485
+ this.loadingPreset = true;
486
+ const store = Alpine.store('global');
487
+
488
+ try {
489
+ const { response, newPassword } = await window.utils.request('/api/config', {
490
+ method: 'POST',
491
+ headers: { 'Content-Type': 'application/json' },
492
+ body: JSON.stringify(preset.config)
493
+ }, store.webuiPassword);
494
+
495
+ if (newPassword) store.webuiPassword = newPassword;
496
+
497
+ const data = await response.json();
498
+ if (data.status === 'ok') {
499
+ store.showToast(store.t('serverPresetLoaded', { name }) || `Preset "${name}" applied`, 'success');
500
+ await this.fetchServerConfig();
501
+ } else {
502
+ throw new Error(data.error || 'Failed to apply preset');
503
+ }
504
+ } catch (e) {
505
+ store.showToast((store.t('failedToLoadServerPreset') || 'Failed to apply preset') + ': ' + e.message, 'error');
506
+ } finally {
507
+ this.loadingPreset = false;
508
+ }
509
+ },
510
+
511
+ /**
512
+ * Save current server config as a new custom preset
513
+ */
514
+ async saveCurrentAsServerPreset() {
515
+ this.editingPresetMode = false;
516
+ this.editingPresetOriginalName = '';
517
+ this.newServerPresetName = '';
518
+ this.newServerPresetDescription = '';
519
+ document.getElementById('save_server_preset_modal').showModal();
520
+ },
521
+
522
+ /**
523
+ * Edit an existing custom preset's name and description
524
+ */
525
+ editServerPreset() {
526
+ const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
527
+ if (!preset || preset.builtIn) return;
528
+
529
+ this.editingPresetMode = true;
530
+ this.editingPresetOriginalName = preset.name;
531
+ this.newServerPresetName = preset.name;
532
+ this.newServerPresetDescription = preset.description || '';
533
+ document.getElementById('save_server_preset_modal').showModal();
534
+ },
535
+
536
+ /**
537
+ * Execute PATCH to update preset metadata
538
+ */
539
+ async executeEditServerPreset() {
540
+ const name = this.newServerPresetName.trim();
541
+ if (!name) {
542
+ Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error');
543
+ return;
544
+ }
545
+
546
+ this.savingServerPreset = true;
547
+ const store = Alpine.store('global');
548
+
549
+ try {
550
+ const payload = { name, description: this.newServerPresetDescription.trim() || '' };
551
+
552
+ const { response, newPassword } = await window.utils.request(
553
+ `/api/server/presets/${encodeURIComponent(this.editingPresetOriginalName)}`,
554
+ {
555
+ method: 'PATCH',
556
+ headers: { 'Content-Type': 'application/json' },
557
+ body: JSON.stringify(payload)
558
+ },
559
+ store.webuiPassword
560
+ );
561
+ if (newPassword) store.webuiPassword = newPassword;
562
+
563
+ if (!response.ok) {
564
+ const errData = await response.json();
565
+ throw new Error(errData.error || `HTTP ${response.status}`);
566
+ }
567
+ const data = await response.json();
568
+ if (data.status === 'ok') {
569
+ this.serverPresets = data.presets || [];
570
+ this.selectedServerPreset = name;
571
+ this.editingPresetMode = false;
572
+ this.editingPresetOriginalName = '';
573
+ this.newServerPresetName = '';
574
+ this.newServerPresetDescription = '';
575
+ store.showToast(store.t('serverPresetUpdated') || 'Preset updated', 'success');
576
+ document.getElementById('save_server_preset_modal').close();
577
+ } else {
578
+ throw new Error(data.error || 'Failed to update preset');
579
+ }
580
+ } catch (e) {
581
+ store.showToast((store.t('failedToEditServerPreset') || 'Failed to update preset') + ': ' + e.message, 'error');
582
+ } finally {
583
+ this.savingServerPreset = false;
584
+ }
585
+ },
586
+
587
+ async executeSaveServerPreset(name) {
588
+ if (!name || !name.trim()) {
589
+ Alpine.store('global').showToast(Alpine.store('global').t('presetNameRequired') || 'Preset name is required', 'error');
590
+ return;
591
+ }
592
+
593
+ this.savingServerPreset = true;
594
+ const store = Alpine.store('global');
595
+ const password = store.webuiPassword;
596
+
597
+ try {
598
+ // Extract relevant config fields (exclude sensitive/non-tunable)
599
+ const relevantKeys = [
600
+ 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs',
601
+ 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold',
602
+ 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs',
603
+ 'maxCapacityRetries', 'switchAccountDelayMs', 'capacityBackoffTiersMs',
604
+ 'accountSelection'
605
+ ];
606
+ const presetConfig = {};
607
+ relevantKeys.forEach(k => {
608
+ if (this.serverConfig[k] !== undefined) {
609
+ presetConfig[k] = JSON.parse(JSON.stringify(this.serverConfig[k]));
610
+ }
611
+ });
612
+
613
+ const payload = { name: name.trim(), config: presetConfig };
614
+ if (this.newServerPresetDescription.trim()) {
615
+ payload.description = this.newServerPresetDescription.trim();
616
+ }
617
+
618
+ const { response, newPassword } = await window.utils.request('/api/server/presets', {
619
+ method: 'POST',
620
+ headers: { 'Content-Type': 'application/json' },
621
+ body: JSON.stringify(payload)
622
+ }, password);
623
+ if (newPassword) store.webuiPassword = newPassword;
624
+
625
+ if (!response.ok) {
626
+ const errData = await response.json();
627
+ throw new Error(errData.error || `HTTP ${response.status}`);
628
+ }
629
+ const data = await response.json();
630
+ if (data.status === 'ok') {
631
+ this.serverPresets = data.presets || [];
632
+ this.selectedServerPreset = name.trim();
633
+ this.newServerPresetName = '';
634
+ this.newServerPresetDescription = '';
635
+ store.showToast(store.t('serverPresetSaved') || `Preset "${name}" saved`, 'success');
636
+ document.getElementById('save_server_preset_modal').close();
637
+ } else {
638
+ throw new Error(data.error || 'Failed to save preset');
639
+ }
640
+ } catch (e) {
641
+ store.showToast((store.t('failedToSaveServerPreset') || 'Failed to save preset') + ': ' + e.message, 'error');
642
+ } finally {
643
+ this.savingServerPreset = false;
644
+ }
645
+ },
646
+
647
+ async deleteSelectedServerPreset() {
648
+ if (!this.selectedServerPreset) return;
649
+
650
+ // Check if built-in
651
+ const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
652
+ if (preset?.builtIn) {
653
+ Alpine.store('global').showToast(Alpine.store('global').t('cannotDeleteBuiltIn') || 'Cannot delete built-in presets', 'warning');
654
+ return;
655
+ }
656
+
657
+ const store = Alpine.store('global');
658
+ const translated = store.t('deletePresetConfirm', { name: this.selectedServerPreset });
659
+ const confirmMsg = translated === 'deletePresetConfirm' ? `Delete preset "${this.selectedServerPreset}"?` : translated;
660
+ if (!confirm(confirmMsg)) return;
661
+
662
+ this.deletingServerPreset = true;
663
+
664
+ try {
665
+ const { response, newPassword } = await window.utils.request(
666
+ `/api/server/presets/${encodeURIComponent(this.selectedServerPreset)}`,
667
+ { method: 'DELETE' },
668
+ store.webuiPassword
669
+ );
670
+ if (newPassword) store.webuiPassword = newPassword;
671
+
672
+ if (!response.ok) {
673
+ const errData = await response.json();
674
+ throw new Error(errData.error || `HTTP ${response.status}`);
675
+ }
676
+ const data = await response.json();
677
+ if (data.status === 'ok') {
678
+ this.serverPresets = data.presets || [];
679
+ this.selectedServerPreset = this.serverPresets.length > 0 ? this.serverPresets[0].name : '';
680
+ store.showToast(store.t('serverPresetDeleted') || 'Preset deleted', 'success');
681
+ } else {
682
+ throw new Error(data.error || 'Failed to delete preset');
683
+ }
684
+ } catch (e) {
685
+ store.showToast((store.t('failedToDeleteServerPreset') || 'Failed to delete preset') + ': ' + e.message, 'error');
686
+ } finally {
687
+ this.deletingServerPreset = false;
688
+ }
689
+ },
690
+
691
+ isSelectedPresetBuiltIn() {
692
+ const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
693
+ return preset?.builtIn === true;
694
+ },
695
+
696
+ /**
697
+ * Format a millisecond value to a human-readable string.
698
+ * e.g. 60000 → "1m", 1000 → "1s", 1500 → "1.5s", 90000 → "1m 30s"
699
+ */
700
+ formatMsValue(ms) {
701
+ if (ms == null) return '—';
702
+ if (ms < 1000) return ms + 'ms';
703
+ const totalSeconds = ms / 1000;
704
+ if (totalSeconds < 60) {
705
+ return Number.isInteger(totalSeconds) ? totalSeconds + 's' : totalSeconds.toFixed(1) + 's';
706
+ }
707
+ const minutes = Math.floor(totalSeconds / 60);
708
+ const seconds = totalSeconds % 60;
709
+ if (seconds === 0) return minutes + 'm';
710
+ return minutes + 'm ' + (Number.isInteger(seconds) ? seconds : seconds.toFixed(1)) + 's';
711
+ },
712
+
713
+ /**
714
+ * Get preview sections for the currently selected preset.
715
+ * Returns { strategy, strategyLabel, sections } where each section has { label, rows }.
716
+ * Each row has { label, value, differs } where differs is true when the preset
717
+ * value doesn't match the current running serverConfig.
718
+ */
719
+ getPresetPreviewSections() {
720
+ const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
721
+ if (!preset?.config) return null;
722
+
723
+ const cfg = preset.config;
724
+ const cur = this.serverConfig;
725
+ const store = Alpine.store('global');
726
+
727
+ const strategy = cfg.accountSelection?.strategy || 'hybrid';
728
+ const currentStrategy = cur.accountSelection?.strategy || 'hybrid';
729
+
730
+ const differs = (presetVal, currentVal) => {
731
+ if (presetVal == null && currentVal == null) return false;
732
+ if (presetVal == null || currentVal == null) return true;
733
+ return JSON.stringify(presetVal) !== JSON.stringify(currentVal);
734
+ };
735
+
736
+ const fmtQuota = (val) => {
737
+ if (!val || val === 0) return store.t('quotaDisabled') || 'Disabled';
738
+ return Math.round(val * 100) + '%';
739
+ };
740
+
741
+ const V = window.AppConstants.VALIDATION;
742
+
743
+ const sections = [
744
+ {
745
+ label: store.t('networkRetry') || 'Network Retry Settings',
746
+ rows: [
747
+ { label: store.t('maxRetries') || 'Max Retries', value: cfg.maxRetries ?? '—', differs: differs(cfg.maxRetries, cur.maxRetries), key: 'maxRetries', min: V.MAX_RETRIES_MIN, max: V.MAX_RETRIES_MAX, step: 1 },
748
+ { label: store.t('retryBaseDelay') || 'Retry Base Delay', value: this.formatMsValue(cfg.retryBaseMs), differs: differs(cfg.retryBaseMs, cur.retryBaseMs), key: 'retryBaseMs', min: V.RETRY_BASE_MS_MIN, max: V.RETRY_BASE_MS_MAX, step: 100, suffix: 'ms' },
749
+ { label: store.t('retryMaxDelay') || 'Retry Max Delay', value: this.formatMsValue(cfg.retryMaxMs), differs: differs(cfg.retryMaxMs, cur.retryMaxMs), key: 'retryMaxMs', min: V.RETRY_MAX_MS_MIN, max: V.RETRY_MAX_MS_MAX, step: 1000, suffix: 'ms' },
750
+ ]
751
+ },
752
+ {
753
+ label: store.t('rateLimiting') || 'Rate Limiting',
754
+ rows: [
755
+ { label: store.t('defaultCooldown') || 'Default Cooldown', value: this.formatMsValue(cfg.defaultCooldownMs), differs: differs(cfg.defaultCooldownMs, cur.defaultCooldownMs), key: 'defaultCooldownMs', min: V.DEFAULT_COOLDOWN_MIN, max: V.DEFAULT_COOLDOWN_MAX, step: 1000, suffix: 'ms' },
756
+ { label: store.t('maxWaitThreshold') || 'Max Wait Before Error', value: this.formatMsValue(cfg.maxWaitBeforeErrorMs), differs: differs(cfg.maxWaitBeforeErrorMs, cur.maxWaitBeforeErrorMs), key: 'maxWaitBeforeErrorMs', min: V.MAX_WAIT_MIN, max: V.MAX_WAIT_MAX, step: 1000, suffix: 'ms' },
757
+ { label: store.t('maxAccounts') || 'Max Accounts', value: cfg.maxAccounts ?? '—', differs: differs(cfg.maxAccounts, cur.maxAccounts), key: 'maxAccounts', min: V.MAX_ACCOUNTS_MIN, max: V.MAX_ACCOUNTS_MAX, step: 1 },
758
+ { label: store.t('switchAccountDelay') || 'Switch Account Delay', value: this.formatMsValue(cfg.switchAccountDelayMs), differs: differs(cfg.switchAccountDelayMs, cur.switchAccountDelayMs), key: 'switchAccountDelayMs', min: V.SWITCH_ACCOUNT_DELAY_MIN, max: V.SWITCH_ACCOUNT_DELAY_MAX, step: 1000, suffix: 'ms' },
759
+ ]
760
+ },
761
+ {
762
+ label: store.t('quotaProtection') || 'Quota Protection',
763
+ rows: [
764
+ { label: store.t('minimumQuotaLevel') || 'Minimum Quota Level', value: fmtQuota(cfg.globalQuotaThreshold), differs: differs(cfg.globalQuotaThreshold, cur.globalQuotaThreshold), key: 'globalQuotaThreshold', min: V.GLOBAL_QUOTA_THRESHOLD_MIN, max: V.GLOBAL_QUOTA_THRESHOLD_MAX, step: 1, suffix: '%' },
765
+ ]
766
+ },
767
+ {
768
+ label: store.t('errorHandlingTuning') || 'Error Handling',
769
+ rows: [
770
+ { label: store.t('rateLimitDedupWindow') || 'Dedup Window', value: this.formatMsValue(cfg.rateLimitDedupWindowMs), differs: differs(cfg.rateLimitDedupWindowMs, cur.rateLimitDedupWindowMs), key: 'rateLimitDedupWindowMs', min: V.RATE_LIMIT_DEDUP_MIN, max: V.RATE_LIMIT_DEDUP_MAX, step: 1000, suffix: 'ms' },
771
+ { label: store.t('maxConsecutiveFailures') || 'Max Consecutive Failures', value: cfg.maxConsecutiveFailures ?? '—', differs: differs(cfg.maxConsecutiveFailures, cur.maxConsecutiveFailures), key: 'maxConsecutiveFailures', min: V.MAX_CONSECUTIVE_FAILURES_MIN, max: V.MAX_CONSECUTIVE_FAILURES_MAX, step: 1 },
772
+ { label: store.t('extendedCooldown') || 'Extended Cooldown', value: this.formatMsValue(cfg.extendedCooldownMs), differs: differs(cfg.extendedCooldownMs, cur.extendedCooldownMs), key: 'extendedCooldownMs', min: V.EXTENDED_COOLDOWN_MIN, max: V.EXTENDED_COOLDOWN_MAX, step: 1000, suffix: 'ms' },
773
+ { label: store.t('maxCapacityRetries') || 'Max Capacity Retries', value: cfg.maxCapacityRetries ?? '—', differs: differs(cfg.maxCapacityRetries, cur.maxCapacityRetries), key: 'maxCapacityRetries', min: V.MAX_CAPACITY_RETRIES_MIN, max: V.MAX_CAPACITY_RETRIES_MAX, step: 1 },
774
+ { label: store.t('capacityBackoffTiers') || 'Capacity Backoff Tiers', value: (cfg.capacityBackoffTiersMs || []).map(v => this.formatMsValue(v)).join(', ') || '—', differs: differs(cfg.capacityBackoffTiersMs, cur.capacityBackoffTiersMs), key: 'capacityBackoffTiersMs', type: 'text' },
775
+ ]
776
+ }
777
+ ];
778
+
779
+ // Add hybrid-only sections when strategy is hybrid
780
+ if (strategy === 'hybrid') {
781
+ const as = cfg.accountSelection || {};
782
+ const curAs = cur.accountSelection || {};
783
+
784
+ // Scoring Weights
785
+ const w = as.weights || {};
786
+ const curW = curAs.weights || {};
787
+ sections.push({
788
+ label: store.t('scoringWeights') || 'Scoring Weights',
789
+ hybridOnly: true,
790
+ rows: [
791
+ { label: store.t('weightHealth') || 'Health Weight', value: w.health ?? '—', differs: differs(w.health, curW.health), key: 'w_health', min: V.W_HEALTH_MIN, max: V.W_HEALTH_MAX, step: 0.1 },
792
+ { label: store.t('weightTokens') || 'Tokens Weight', value: w.tokens ?? '—', differs: differs(w.tokens, curW.tokens), key: 'w_tokens', min: V.W_TOKENS_MIN, max: V.W_TOKENS_MAX, step: 0.1 },
793
+ { label: store.t('weightQuota') || 'Quota Weight', value: w.quota ?? '—', differs: differs(w.quota, curW.quota), key: 'w_quota', min: V.W_QUOTA_MIN, max: V.W_QUOTA_MAX, step: 0.1 },
794
+ { label: store.t('weightLru') || 'LRU Weight', value: w.lru ?? '—', differs: differs(w.lru, curW.lru), key: 'w_lru', min: V.W_LRU_MIN, max: V.W_LRU_MAX, step: 0.01 },
795
+ ]
796
+ });
797
+
798
+ // Health Score
799
+ const hs = as.healthScore || {};
800
+ const curHs = curAs.healthScore || {};
801
+ sections.push({
802
+ label: store.t('healthScoreSection') || 'Health Score',
803
+ hybridOnly: true,
804
+ rows: [
805
+ { label: store.t('hsInitial') || 'Initial Score', value: hs.initial ?? '—', differs: differs(hs.initial, curHs.initial), key: 'hs_initial', min: V.HS_INITIAL_MIN, max: V.HS_INITIAL_MAX, step: 1 },
806
+ { label: store.t('hsSuccessReward') || 'Success Reward', value: hs.successReward ?? '—', differs: differs(hs.successReward, curHs.successReward), key: 'hs_successReward', min: V.HS_SUCCESS_REWARD_MIN, max: V.HS_SUCCESS_REWARD_MAX, step: 1 },
807
+ { label: store.t('hsRateLimitPenalty') || 'Rate Limit Penalty', value: hs.rateLimitPenalty ?? '—', differs: differs(hs.rateLimitPenalty, curHs.rateLimitPenalty), key: 'hs_rateLimitPenalty', min: V.HS_RATE_LIMIT_PENALTY_MIN, max: V.HS_RATE_LIMIT_PENALTY_MAX, step: 1 },
808
+ { label: store.t('hsFailurePenalty') || 'Failure Penalty', value: hs.failurePenalty ?? '—', differs: differs(hs.failurePenalty, curHs.failurePenalty), key: 'hs_failurePenalty', min: V.HS_FAILURE_PENALTY_MIN, max: V.HS_FAILURE_PENALTY_MAX, step: 1 },
809
+ { label: store.t('hsRecoveryPerHour') || 'Recovery/Hour', value: hs.recoveryPerHour ?? '—', differs: differs(hs.recoveryPerHour, curHs.recoveryPerHour), key: 'hs_recoveryPerHour', min: V.HS_RECOVERY_PER_HOUR_MIN, max: V.HS_RECOVERY_PER_HOUR_MAX, step: 1 },
810
+ { label: store.t('hsMinUsable') || 'Min Usable Score', value: hs.minUsable ?? '—', differs: differs(hs.minUsable, curHs.minUsable), key: 'hs_minUsable', min: V.HS_MIN_USABLE_MIN, max: V.HS_MIN_USABLE_MAX, step: 1 },
811
+ { label: store.t('hsMaxScore') || 'Max Score', value: hs.maxScore ?? '—', differs: differs(hs.maxScore, curHs.maxScore), key: 'hs_maxScore', min: V.HS_MAX_SCORE_MIN, max: V.HS_MAX_SCORE_MAX, step: 1 },
812
+ ]
813
+ });
814
+
815
+ // Token Bucket
816
+ const tb = as.tokenBucket || {};
817
+ const curTb = curAs.tokenBucket || {};
818
+ sections.push({
819
+ label: store.t('tokenBucketSection') || 'Token Bucket',
820
+ hybridOnly: true,
821
+ rows: [
822
+ { label: store.t('tbMaxTokens') || 'Max Tokens', value: tb.maxTokens ?? '—', differs: differs(tb.maxTokens, curTb.maxTokens), key: 'tb_maxTokens', min: V.TB_MAX_TOKENS_MIN, max: V.TB_MAX_TOKENS_MAX, step: 1 },
823
+ { label: store.t('tbTokensPerMinute') || 'Tokens/Minute', value: tb.tokensPerMinute ?? '—', differs: differs(tb.tokensPerMinute, curTb.tokensPerMinute), key: 'tb_tokensPerMinute', min: V.TB_TOKENS_PER_MINUTE_MIN, max: V.TB_TOKENS_PER_MINUTE_MAX, step: 1 },
824
+ { label: store.t('tbInitialTokens') || 'Initial Tokens', value: tb.initialTokens ?? '—', differs: differs(tb.initialTokens, curTb.initialTokens), key: 'tb_initialTokens', min: V.TB_INITIAL_TOKENS_MIN, max: V.TB_INITIAL_TOKENS_MAX, step: 1 },
825
+ ]
826
+ });
827
+
828
+ // Quota Awareness
829
+ const q = as.quota || {};
830
+ const curQ = curAs.quota || {};
831
+ sections.push({
832
+ label: store.t('quotaAwarenessSection') || 'Quota Awareness',
833
+ hybridOnly: true,
834
+ rows: [
835
+ { label: store.t('qLowThreshold') || 'Low Threshold', value: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) + '%' : '—', differs: differs(q.lowThreshold, curQ.lowThreshold), key: 'q_lowThreshold', min: V.Q_LOW_THRESHOLD_MIN, max: V.Q_LOW_THRESHOLD_MAX, step: 1, suffix: '%' },
836
+ { label: store.t('qCriticalThreshold') || 'Critical Threshold', value: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) + '%' : '—', differs: differs(q.criticalThreshold, curQ.criticalThreshold), key: 'q_criticalThreshold', min: V.Q_CRITICAL_THRESHOLD_MIN, max: V.Q_CRITICAL_THRESHOLD_MAX, step: 1, suffix: '%' },
837
+ { label: store.t('qStaleMs') || 'Stale Data Timeout', value: this.formatMsValue(q.staleMs), differs: differs(q.staleMs, curQ.staleMs), key: 'q_staleMs', min: V.Q_STALE_MS_MIN, max: V.Q_STALE_MS_MAX, step: 1000, suffix: 'ms' },
838
+ ]
839
+ });
840
+ }
841
+
842
+ return {
843
+ strategy,
844
+ strategyLabel: this.getStrategyLabel(strategy),
845
+ strategyDiffers: differs(strategy, currentStrategy),
846
+ sections
847
+ };
848
+ },
849
+
850
+ // ==========================================
851
+ // Inline Preset Config Editing
852
+ // ==========================================
853
+
854
+ /**
855
+ * Flatten a nested config object into a flat draft for UI editing.
856
+ * Converts fractions to percentages where needed.
857
+ */
858
+ flattenConfigToDraft(cfg) {
859
+ const as = cfg.accountSelection || {};
860
+ const hs = as.healthScore || {};
861
+ const tb = as.tokenBucket || {};
862
+ const q = as.quota || {};
863
+ const w = as.weights || {};
864
+ return {
865
+ maxRetries: cfg.maxRetries,
866
+ retryBaseMs: cfg.retryBaseMs,
867
+ retryMaxMs: cfg.retryMaxMs,
868
+ defaultCooldownMs: cfg.defaultCooldownMs,
869
+ maxWaitBeforeErrorMs: cfg.maxWaitBeforeErrorMs,
870
+ maxAccounts: cfg.maxAccounts,
871
+ globalQuotaThreshold: cfg.globalQuotaThreshold ? Math.round(cfg.globalQuotaThreshold * 100) : 0,
872
+ rateLimitDedupWindowMs: cfg.rateLimitDedupWindowMs,
873
+ maxConsecutiveFailures: cfg.maxConsecutiveFailures,
874
+ extendedCooldownMs: cfg.extendedCooldownMs,
875
+ maxCapacityRetries: cfg.maxCapacityRetries,
876
+ switchAccountDelayMs: cfg.switchAccountDelayMs,
877
+ capacityBackoffTiersMs: Array.isArray(cfg.capacityBackoffTiersMs) ? cfg.capacityBackoffTiersMs.join(', ') : '',
878
+ accountSelection: { strategy: as.strategy || 'hybrid' },
879
+ // Health score (prefixed hs_)
880
+ hs_initial: hs.initial,
881
+ hs_successReward: hs.successReward,
882
+ hs_rateLimitPenalty: hs.rateLimitPenalty,
883
+ hs_failurePenalty: hs.failurePenalty,
884
+ hs_recoveryPerHour: hs.recoveryPerHour,
885
+ hs_minUsable: hs.minUsable,
886
+ hs_maxScore: hs.maxScore,
887
+ // Token bucket (prefixed tb_)
888
+ tb_maxTokens: tb.maxTokens,
889
+ tb_tokensPerMinute: tb.tokensPerMinute,
890
+ tb_initialTokens: tb.initialTokens,
891
+ // Quota (prefixed q_, fractions → percentages)
892
+ q_lowThreshold: q.lowThreshold != null ? Math.round(q.lowThreshold * 100) : 0,
893
+ q_criticalThreshold: q.criticalThreshold != null ? Math.round(q.criticalThreshold * 100) : 0,
894
+ q_staleMs: q.staleMs,
895
+ // Weights (prefixed w_)
896
+ w_health: w.health,
897
+ w_tokens: w.tokens,
898
+ w_quota: w.quota,
899
+ w_lru: w.lru,
900
+ };
901
+ },
902
+
903
+ /**
904
+ * Build a nested config object from a flat UI draft.
905
+ * Converts percentages back to fractions where needed.
906
+ */
907
+ buildConfigFromDraft(draft) {
908
+ const cfg = {
909
+ maxRetries: draft.maxRetries,
910
+ retryBaseMs: draft.retryBaseMs,
911
+ retryMaxMs: draft.retryMaxMs,
912
+ defaultCooldownMs: draft.defaultCooldownMs,
913
+ maxWaitBeforeErrorMs: draft.maxWaitBeforeErrorMs,
914
+ maxAccounts: draft.maxAccounts,
915
+ globalQuotaThreshold: (draft.globalQuotaThreshold || 0) / 100,
916
+ rateLimitDedupWindowMs: draft.rateLimitDedupWindowMs,
917
+ maxConsecutiveFailures: draft.maxConsecutiveFailures,
918
+ extendedCooldownMs: draft.extendedCooldownMs,
919
+ maxCapacityRetries: draft.maxCapacityRetries,
920
+ switchAccountDelayMs: draft.switchAccountDelayMs,
921
+ };
922
+
923
+ // Parse capacityBackoffTiersMs from comma-separated string
924
+ if (typeof draft.capacityBackoffTiersMs === 'string' && draft.capacityBackoffTiersMs.trim()) {
925
+ const tiers = draft.capacityBackoffTiersMs.split(',').map(s => Number(s.trim())).filter(n => !isNaN(n));
926
+ if (tiers.length > 0) cfg.capacityBackoffTiersMs = tiers;
927
+ } else if (Array.isArray(draft.capacityBackoffTiersMs)) {
928
+ cfg.capacityBackoffTiersMs = draft.capacityBackoffTiersMs;
929
+ }
930
+
931
+ // Build accountSelection with nested objects
932
+ cfg.accountSelection = {
933
+ strategy: draft.accountSelection?.strategy || 'hybrid',
934
+ healthScore: {
935
+ initial: draft.hs_initial,
936
+ successReward: draft.hs_successReward,
937
+ rateLimitPenalty: draft.hs_rateLimitPenalty,
938
+ failurePenalty: draft.hs_failurePenalty,
939
+ recoveryPerHour: draft.hs_recoveryPerHour,
940
+ minUsable: draft.hs_minUsable,
941
+ maxScore: draft.hs_maxScore,
942
+ },
943
+ tokenBucket: {
944
+ maxTokens: draft.tb_maxTokens,
945
+ tokensPerMinute: draft.tb_tokensPerMinute,
946
+ initialTokens: draft.tb_initialTokens,
947
+ },
948
+ quota: {
949
+ lowThreshold: (draft.q_lowThreshold || 0) / 100,
950
+ criticalThreshold: (draft.q_criticalThreshold || 0) / 100,
951
+ staleMs: draft.q_staleMs,
952
+ },
953
+ weights: {
954
+ health: draft.w_health,
955
+ tokens: draft.w_tokens,
956
+ quota: draft.w_quota,
957
+ lru: draft.w_lru,
958
+ }
959
+ };
960
+
961
+ // Clean undefined values from nested objects
962
+ for (const subKey of ['healthScore', 'tokenBucket', 'quota', 'weights']) {
963
+ const obj = cfg.accountSelection[subKey];
964
+ const cleaned = {};
965
+ let hasValues = false;
966
+ for (const [k, v] of Object.entries(obj)) {
967
+ if (v !== undefined && v !== null) {
968
+ cleaned[k] = v;
969
+ hasValues = true;
970
+ }
971
+ }
972
+ if (hasValues) {
973
+ cfg.accountSelection[subKey] = cleaned;
974
+ } else {
975
+ delete cfg.accountSelection[subKey];
976
+ }
977
+ }
978
+
979
+ return cfg;
980
+ },
981
+
982
+ enterPresetConfigEdit() {
983
+ const preset = this.serverPresets.find(p => p.name === this.selectedServerPreset);
984
+ if (!preset?.config || preset.builtIn) return;
985
+
986
+ this.editingConfigDraft = this.flattenConfigToDraft(preset.config);
987
+ this.editingConfigErrors = {};
988
+ this.editingPresetConfig = true;
989
+ this.presetPreviewExpanded = true;
990
+ this.configEditMode = 'ui';
991
+ this.editingJsonText = '';
992
+ this.jsonParseError = null;
993
+ },
994
+
995
+ cancelPresetConfigEdit() {
996
+ this.editingPresetConfig = false;
997
+ this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } };
998
+ this.editingConfigErrors = {};
999
+ this.configEditMode = 'ui';
1000
+ this.editingJsonText = '';
1001
+ this.jsonParseError = null;
1002
+ },
1003
+
1004
+ switchConfigEditMode(mode) {
1005
+ if (mode === this.configEditMode) return;
1006
+
1007
+ if (mode === 'json') {
1008
+ // UI → JSON: convert flat draft to nested config, then stringify
1009
+ const nested = this.buildConfigFromDraft(this.editingConfigDraft);
1010
+ this.editingJsonText = JSON.stringify(nested, null, 2);
1011
+ this.jsonParseError = null;
1012
+ this.editingConfigErrors = {};
1013
+ this.configEditMode = 'json';
1014
+ } else {
1015
+ // JSON → UI: parse nested config and flatten to draft
1016
+ try {
1017
+ const parsed = JSON.parse(this.editingJsonText);
1018
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1019
+ this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
1020
+ return;
1021
+ }
1022
+ this.editingConfigDraft = this.flattenConfigToDraft(parsed);
1023
+ this.editingConfigErrors = {};
1024
+ this.jsonParseError = null;
1025
+ // Re-validate all numeric fields
1026
+ const allKeys = [
1027
+ 'maxRetries', 'retryBaseMs', 'retryMaxMs', 'defaultCooldownMs',
1028
+ 'maxWaitBeforeErrorMs', 'maxAccounts', 'globalQuotaThreshold',
1029
+ 'rateLimitDedupWindowMs', 'maxConsecutiveFailures', 'extendedCooldownMs',
1030
+ 'maxCapacityRetries', 'switchAccountDelayMs',
1031
+ 'hs_initial', 'hs_successReward', 'hs_rateLimitPenalty', 'hs_failurePenalty',
1032
+ 'hs_recoveryPerHour', 'hs_minUsable', 'hs_maxScore',
1033
+ 'tb_maxTokens', 'tb_tokensPerMinute', 'tb_initialTokens',
1034
+ 'q_lowThreshold', 'q_criticalThreshold', 'q_staleMs',
1035
+ 'w_health', 'w_tokens', 'w_quota', 'w_lru'
1036
+ ];
1037
+ allKeys.forEach(k => {
1038
+ if (this.editingConfigDraft[k] !== undefined) this.validatePresetConfigField(k, this.editingConfigDraft[k]);
1039
+ });
1040
+ // Validate capacityBackoffTiersMs text
1041
+ this.validatePresetConfigField('capacityBackoffTiersMs', this.editingConfigDraft.capacityBackoffTiersMs);
1042
+ this.configEditMode = 'ui';
1043
+ } catch (e) {
1044
+ this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
1045
+ }
1046
+ }
1047
+ },
1048
+
1049
+ validateJsonText() {
1050
+ try {
1051
+ const parsed = JSON.parse(this.editingJsonText);
1052
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
1053
+ this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
1054
+ } else {
1055
+ this.jsonParseError = null;
1056
+ }
1057
+ } catch {
1058
+ this.jsonParseError = Alpine.store('global').t('invalidJson') || 'Invalid JSON';
1059
+ }
1060
+ },
1061
+
1062
+ validatePresetConfigField(key, value) {
1063
+ const V = window.AppConstants.VALIDATION;
1064
+
1065
+ // Special text validation for capacityBackoffTiersMs
1066
+ if (key === 'capacityBackoffTiersMs') {
1067
+ const str = String(value || '').trim();
1068
+ if (!str) {
1069
+ delete this.editingConfigErrors[key];
1070
+ return;
1071
+ }
1072
+ const parts = str.split(',').map(s => s.trim());
1073
+ if (parts.length < V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH || parts.length > V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH) {
1074
+ this.editingConfigErrors[key] = `${V.CAPACITY_BACKOFF_TIERS_MIN_LENGTH}–${V.CAPACITY_BACKOFF_TIERS_MAX_LENGTH} values`;
1075
+ return;
1076
+ }
1077
+ const allValid = parts.every(s => {
1078
+ const n = Number(s);
1079
+ return !isNaN(n) && n >= V.CAPACITY_BACKOFF_TIER_MIN && n <= V.CAPACITY_BACKOFF_TIER_MAX;
1080
+ });
1081
+ if (!allValid) {
1082
+ this.editingConfigErrors[key] = `each ${V.CAPACITY_BACKOFF_TIER_MIN}–${V.CAPACITY_BACKOFF_TIER_MAX}`;
1083
+ } else {
1084
+ delete this.editingConfigErrors[key];
1085
+ }
1086
+ return;
1087
+ }
1088
+
1089
+ const numVal = Number(value);
1090
+ const ranges = {
1091
+ maxRetries: [V.MAX_RETRIES_MIN, V.MAX_RETRIES_MAX],
1092
+ retryBaseMs: [V.RETRY_BASE_MS_MIN, V.RETRY_BASE_MS_MAX],
1093
+ retryMaxMs: [V.RETRY_MAX_MS_MIN, V.RETRY_MAX_MS_MAX],
1094
+ defaultCooldownMs: [V.DEFAULT_COOLDOWN_MIN, V.DEFAULT_COOLDOWN_MAX],
1095
+ maxWaitBeforeErrorMs: [V.MAX_WAIT_MIN, V.MAX_WAIT_MAX],
1096
+ maxAccounts: [V.MAX_ACCOUNTS_MIN, V.MAX_ACCOUNTS_MAX],
1097
+ globalQuotaThreshold: [V.GLOBAL_QUOTA_THRESHOLD_MIN, V.GLOBAL_QUOTA_THRESHOLD_MAX],
1098
+ rateLimitDedupWindowMs: [V.RATE_LIMIT_DEDUP_MIN, V.RATE_LIMIT_DEDUP_MAX],
1099
+ maxConsecutiveFailures: [V.MAX_CONSECUTIVE_FAILURES_MIN, V.MAX_CONSECUTIVE_FAILURES_MAX],
1100
+ extendedCooldownMs: [V.EXTENDED_COOLDOWN_MIN, V.EXTENDED_COOLDOWN_MAX],
1101
+ maxCapacityRetries: [V.MAX_CAPACITY_RETRIES_MIN, V.MAX_CAPACITY_RETRIES_MAX],
1102
+ switchAccountDelayMs: [V.SWITCH_ACCOUNT_DELAY_MIN, V.SWITCH_ACCOUNT_DELAY_MAX],
1103
+ // Health score
1104
+ hs_initial: [V.HS_INITIAL_MIN, V.HS_INITIAL_MAX],
1105
+ hs_successReward: [V.HS_SUCCESS_REWARD_MIN, V.HS_SUCCESS_REWARD_MAX],
1106
+ hs_rateLimitPenalty: [V.HS_RATE_LIMIT_PENALTY_MIN, V.HS_RATE_LIMIT_PENALTY_MAX],
1107
+ hs_failurePenalty: [V.HS_FAILURE_PENALTY_MIN, V.HS_FAILURE_PENALTY_MAX],
1108
+ hs_recoveryPerHour: [V.HS_RECOVERY_PER_HOUR_MIN, V.HS_RECOVERY_PER_HOUR_MAX],
1109
+ hs_minUsable: [V.HS_MIN_USABLE_MIN, V.HS_MIN_USABLE_MAX],
1110
+ hs_maxScore: [V.HS_MAX_SCORE_MIN, V.HS_MAX_SCORE_MAX],
1111
+ // Token bucket
1112
+ tb_maxTokens: [V.TB_MAX_TOKENS_MIN, V.TB_MAX_TOKENS_MAX],
1113
+ tb_tokensPerMinute: [V.TB_TOKENS_PER_MINUTE_MIN, V.TB_TOKENS_PER_MINUTE_MAX],
1114
+ tb_initialTokens: [V.TB_INITIAL_TOKENS_MIN, V.TB_INITIAL_TOKENS_MAX],
1115
+ // Quota
1116
+ q_lowThreshold: [V.Q_LOW_THRESHOLD_MIN, V.Q_LOW_THRESHOLD_MAX],
1117
+ q_criticalThreshold: [V.Q_CRITICAL_THRESHOLD_MIN, V.Q_CRITICAL_THRESHOLD_MAX],
1118
+ q_staleMs: [V.Q_STALE_MS_MIN, V.Q_STALE_MS_MAX],
1119
+ // Weights
1120
+ w_health: [V.W_HEALTH_MIN, V.W_HEALTH_MAX],
1121
+ w_tokens: [V.W_TOKENS_MIN, V.W_TOKENS_MAX],
1122
+ w_quota: [V.W_QUOTA_MIN, V.W_QUOTA_MAX],
1123
+ w_lru: [V.W_LRU_MIN, V.W_LRU_MAX],
1124
+ };
1125
+ const range = ranges[key];
1126
+ if (!range) return;
1127
+
1128
+ if (isNaN(numVal) || numVal < range[0] || numVal > range[1]) {
1129
+ this.editingConfigErrors[key] = `${range[0]}–${range[1]}`;
1130
+ } else {
1131
+ delete this.editingConfigErrors[key];
1132
+ this.editingConfigDraft[key] = numVal;
1133
+ }
1134
+ },
1135
+
1136
+ hasPresetConfigErrors() {
1137
+ if (this.configEditMode === 'json') return !!this.jsonParseError;
1138
+ return Object.keys(this.editingConfigErrors).length > 0;
1139
+ },
1140
+
1141
+ async savePresetConfig() {
1142
+ if (this.hasPresetConfigErrors() || this.savingPresetConfig) return;
1143
+
1144
+ const store = Alpine.store('global');
1145
+ const presetName = this.selectedServerPreset;
1146
+ if (!presetName) return;
1147
+
1148
+ let configPayload;
1149
+ if (this.configEditMode === 'json') {
1150
+ // Parse JSON textarea directly into config payload
1151
+ try {
1152
+ configPayload = JSON.parse(this.editingJsonText);
1153
+ } catch {
1154
+ this.jsonParseError = store.t('invalidJson') || 'Invalid JSON';
1155
+ return;
1156
+ }
1157
+ // Must be a plain object (not null, not an array, not a primitive)
1158
+ if (typeof configPayload !== 'object' || configPayload === null || Array.isArray(configPayload)) {
1159
+ this.jsonParseError = store.t('invalidJson') || 'Invalid JSON';
1160
+ return;
1161
+ }
1162
+ } else {
1163
+ // Build nested config from flat UI draft
1164
+ configPayload = this.buildConfigFromDraft(this.editingConfigDraft);
1165
+ }
1166
+
1167
+ this.savingPresetConfig = true;
1168
+ try {
1169
+ const { response, newPassword } = await window.utils.request(
1170
+ `/api/server/presets/${encodeURIComponent(presetName)}`,
1171
+ {
1172
+ method: 'PATCH',
1173
+ headers: { 'Content-Type': 'application/json' },
1174
+ body: JSON.stringify({ config: configPayload })
1175
+ },
1176
+ store.webuiPassword
1177
+ );
1178
+ if (newPassword) store.webuiPassword = newPassword;
1179
+
1180
+ if (!response.ok) {
1181
+ const errData = await response.json();
1182
+ throw new Error(errData.error || `HTTP ${response.status}`);
1183
+ }
1184
+ const data = await response.json();
1185
+ if (data.status === 'ok') {
1186
+ this.serverPresets = data.presets || [];
1187
+ this.editingPresetConfig = false;
1188
+ this.editingConfigDraft = { accountSelection: { strategy: 'hybrid' } };
1189
+ this.editingConfigErrors = {};
1190
+ this.configEditMode = 'ui';
1191
+ this.editingJsonText = '';
1192
+ this.jsonParseError = null;
1193
+ store.showToast(store.t('presetConfigSaved') || 'Preset config updated', 'success');
1194
+ } else {
1195
+ throw new Error(data.error || 'Failed to save preset config');
1196
+ }
1197
+ } catch (e) {
1198
+ store.showToast((store.t('failedToSavePresetConfig') || 'Failed to update preset config') + ': ' + e.message, 'error');
1199
+ } finally {
1200
+ this.savingPresetConfig = false;
1201
+ }
1202
+ }
1203
+ });
public/js/config/constants.js ADDED
@@ -0,0 +1,165 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Application Constants
3
+ * Centralized configuration values and magic numbers
4
+ */
5
+ window.AppConstants = window.AppConstants || {};
6
+
7
+ /**
8
+ * Time intervals (in milliseconds)
9
+ */
10
+ window.AppConstants.INTERVALS = {
11
+ // Dashboard refresh interval (5 minutes)
12
+ DASHBOARD_REFRESH: 300000,
13
+
14
+ // OAuth message handler timeout (5 minutes)
15
+ OAUTH_MESSAGE_TIMEOUT: 300000,
16
+
17
+ // Server config debounce delay
18
+ CONFIG_DEBOUNCE: 500,
19
+
20
+ // General short delay (for UI transitions)
21
+ SHORT_DELAY: 2000
22
+ };
23
+
24
+ /**
25
+ * Data limits and quotas
26
+ */
27
+ window.AppConstants.LIMITS = {
28
+ // Default log limit
29
+ DEFAULT_LOG_LIMIT: 2000,
30
+
31
+ // Minimum quota value
32
+ MIN_QUOTA: 100,
33
+
34
+ // Percentage base (for calculations)
35
+ PERCENTAGE_BASE: 100,
36
+
37
+ // Max per-account breakdown rows before showing "+N more" toggle
38
+ ACCOUNT_BREAKDOWN_LIMIT: 10
39
+ };
40
+
41
+ /**
42
+ * Validation ranges
43
+ */
44
+ window.AppConstants.VALIDATION = {
45
+ // Port range
46
+ PORT_MIN: 1,
47
+ PORT_MAX: 65535,
48
+
49
+ // Timeout range (0 - 5 minutes)
50
+ TIMEOUT_MIN: 0,
51
+ TIMEOUT_MAX: 300000,
52
+
53
+ // Log limit range
54
+ LOG_LIMIT_MIN: 100,
55
+ LOG_LIMIT_MAX: 10000,
56
+
57
+ // Retry configuration ranges
58
+ MAX_RETRIES_MIN: 1,
59
+ MAX_RETRIES_MAX: 20,
60
+
61
+ RETRY_BASE_MS_MIN: 100,
62
+ RETRY_BASE_MS_MAX: 10000,
63
+
64
+ RETRY_MAX_MS_MIN: 1000,
65
+ RETRY_MAX_MS_MAX: 120000,
66
+
67
+ // Cooldown range (1s - 5 minutes)
68
+ DEFAULT_COOLDOWN_MIN: 1000,
69
+ DEFAULT_COOLDOWN_MAX: 300000,
70
+
71
+ // Max wait threshold (0 - 10 minutes)
72
+ MAX_WAIT_MIN: 0,
73
+ MAX_WAIT_MAX: 600000,
74
+
75
+ // Max accounts range (1 - 100)
76
+ MAX_ACCOUNTS_MIN: 1,
77
+ MAX_ACCOUNTS_MAX: 100,
78
+
79
+ // Rate limit dedup window (1 - 30 seconds)
80
+ RATE_LIMIT_DEDUP_MIN: 1000,
81
+ RATE_LIMIT_DEDUP_MAX: 30000,
82
+
83
+ // Consecutive failures (1 - 10)
84
+ MAX_CONSECUTIVE_FAILURES_MIN: 1,
85
+ MAX_CONSECUTIVE_FAILURES_MAX: 10,
86
+
87
+ // Extended cooldown (10 seconds - 5 minutes)
88
+ EXTENDED_COOLDOWN_MIN: 10000,
89
+ EXTENDED_COOLDOWN_MAX: 300000,
90
+
91
+ // Capacity retries (1 - 10)
92
+ MAX_CAPACITY_RETRIES_MIN: 1,
93
+ MAX_CAPACITY_RETRIES_MAX: 10,
94
+
95
+ // Global quota threshold (0 - 99%)
96
+ GLOBAL_QUOTA_THRESHOLD_MIN: 0,
97
+ GLOBAL_QUOTA_THRESHOLD_MAX: 99,
98
+
99
+ // Request delay (100 - 5000ms)
100
+ REQUEST_DELAY_MIN: 100,
101
+ REQUEST_DELAY_MAX: 5000,
102
+
103
+ // Switch account delay (1s - 60s)
104
+ SWITCH_ACCOUNT_DELAY_MIN: 1000,
105
+ SWITCH_ACCOUNT_DELAY_MAX: 60000,
106
+
107
+ // Capacity backoff tiers
108
+ CAPACITY_BACKOFF_TIER_MIN: 1000,
109
+ CAPACITY_BACKOFF_TIER_MAX: 300000,
110
+ CAPACITY_BACKOFF_TIERS_MIN_LENGTH: 1,
111
+ CAPACITY_BACKOFF_TIERS_MAX_LENGTH: 10,
112
+
113
+ // Health score fields
114
+ HS_INITIAL_MIN: 0,
115
+ HS_INITIAL_MAX: 100,
116
+ HS_SUCCESS_REWARD_MIN: 0,
117
+ HS_SUCCESS_REWARD_MAX: 20,
118
+ HS_RATE_LIMIT_PENALTY_MIN: -50,
119
+ HS_RATE_LIMIT_PENALTY_MAX: 0,
120
+ HS_FAILURE_PENALTY_MIN: -50,
121
+ HS_FAILURE_PENALTY_MAX: 0,
122
+ HS_RECOVERY_PER_HOUR_MIN: 0,
123
+ HS_RECOVERY_PER_HOUR_MAX: 20,
124
+ HS_MIN_USABLE_MIN: 0,
125
+ HS_MIN_USABLE_MAX: 100,
126
+ HS_MAX_SCORE_MIN: 1,
127
+ HS_MAX_SCORE_MAX: 200,
128
+
129
+ // Token bucket fields
130
+ TB_MAX_TOKENS_MIN: 5,
131
+ TB_MAX_TOKENS_MAX: 200,
132
+ TB_TOKENS_PER_MINUTE_MIN: 1,
133
+ TB_TOKENS_PER_MINUTE_MAX: 60,
134
+ TB_INITIAL_TOKENS_MIN: 1,
135
+ TB_INITIAL_TOKENS_MAX: 200,
136
+
137
+ // Quota awareness fields (displayed as 0-99% in UI)
138
+ Q_LOW_THRESHOLD_MIN: 0,
139
+ Q_LOW_THRESHOLD_MAX: 99,
140
+ Q_CRITICAL_THRESHOLD_MIN: 0,
141
+ Q_CRITICAL_THRESHOLD_MAX: 99,
142
+ Q_STALE_MS_MIN: 30000,
143
+ Q_STALE_MS_MAX: 3600000,
144
+
145
+ // Weights fields
146
+ W_HEALTH_MIN: 0,
147
+ W_HEALTH_MAX: 20,
148
+ W_TOKENS_MIN: 0,
149
+ W_TOKENS_MAX: 20,
150
+ W_QUOTA_MIN: 0,
151
+ W_QUOTA_MAX: 20,
152
+ W_LRU_MIN: 0,
153
+ W_LRU_MAX: 5
154
+ };
155
+
156
+ /**
157
+ * UI Constants
158
+ */
159
+ window.AppConstants.UI = {
160
+ // Toast auto-dismiss duration
161
+ TOAST_DURATION: 3000,
162
+
163
+ // Loading spinner delay
164
+ LOADING_DELAY: 200
165
+ };
public/js/data-store.js ADDED
@@ -0,0 +1,538 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Data Store
3
+ * Holds Accounts, Models, and Computed Quota Rows
4
+ * Shared between Dashboard and AccountManager
5
+ */
6
+
7
+ // utils is loaded globally as window.utils in utils.js
8
+
9
+ document.addEventListener('alpine:init', () => {
10
+ Alpine.store('data', {
11
+ accounts: [],
12
+ models: [], // Source of truth
13
+ modelConfig: {}, // Model metadata (hidden, pinned, alias)
14
+ quotaRows: [], // Filtered view
15
+ usageHistory: {}, // Usage statistics history (from /account-limits?includeHistory=true)
16
+ globalQuotaThreshold: 0, // Global minimum quota threshold (fraction 0-0.99)
17
+ maxAccounts: 10, // Maximum number of accounts allowed (from config)
18
+ devMode: false, // Developer mode flag (from server config)
19
+ placeholderMode: false, // Inject placeholder account data for UI testing
20
+ placeholderIncludeReal: true, // Include real accounts alongside placeholder data
21
+ _realAccounts: null, // Stash for real accounts when placeholder mode is on
22
+ _realModels: null, // Stash for real models when placeholder mode is on
23
+ loading: false,
24
+ initialLoad: true, // Track first load for skeleton screen
25
+ connectionStatus: 'connecting',
26
+ lastUpdated: '-',
27
+ healthCheckTimer: null,
28
+
29
+ // Filters state
30
+ filters: {
31
+ account: 'all',
32
+ family: 'all',
33
+ search: '',
34
+ sortCol: 'avgQuota',
35
+ sortAsc: true
36
+ },
37
+
38
+ // Settings for calculation
39
+ // We need to access global settings? Or duplicate?
40
+ // Let's assume settings are passed or in another store.
41
+ // For simplicity, let's keep relevant filters here.
42
+
43
+ init() {
44
+ // Restore from cache first for instant render
45
+ this.loadFromCache();
46
+
47
+ // Restore placeholder mode from persisted settings
48
+ // Read localStorage directly since settings store may not be initialized yet
49
+ try {
50
+ const saved = JSON.parse(localStorage.getItem('antigravity_settings') || '{}');
51
+ if (saved.placeholderMode) {
52
+ this.setPlaceholderMode(true, saved.placeholderIncludeReal !== false);
53
+ }
54
+ } catch (e) { /* ignore parse errors */ }
55
+
56
+ // Watch filters to recompute
57
+ // Alpine stores don't have $watch automatically unless inside a component?
58
+ // We can manually call compute when filters change.
59
+
60
+ // Start health check monitoring
61
+ this.startHealthCheck();
62
+ },
63
+
64
+ loadFromCache() {
65
+ try {
66
+ const cached = localStorage.getItem('ag_data_cache');
67
+ if (cached) {
68
+ const data = JSON.parse(cached);
69
+ const CACHE_TTL = 24 * 60 * 60 * 1000; // 24 hours
70
+
71
+ // Check TTL
72
+ if (data.timestamp && (Date.now() - data.timestamp > CACHE_TTL)) {
73
+ if (window.UILogger) window.UILogger.debug('Cache expired, skipping restoration');
74
+ localStorage.removeItem('ag_data_cache');
75
+ return;
76
+ }
77
+
78
+ // Basic validity check
79
+ if (data.accounts && data.models) {
80
+ this.accounts = data.accounts;
81
+ this.models = data.models;
82
+ this.modelConfig = data.modelConfig || {};
83
+ this.usageHistory = data.usageHistory || {};
84
+
85
+ // Don't show loading on initial load if we have cache
86
+ this.initialLoad = false;
87
+ this.computeQuotaRows();
88
+ if (window.UILogger) window.UILogger.debug('Restored data from cache');
89
+ }
90
+ }
91
+ } catch (e) {
92
+ if (window.UILogger) window.UILogger.debug('Failed to load cache', e.message);
93
+ }
94
+ },
95
+
96
+ saveToCache() {
97
+ try {
98
+ const cacheData = {
99
+ accounts: this.accounts,
100
+ models: this.models,
101
+ modelConfig: this.modelConfig,
102
+ usageHistory: this.usageHistory,
103
+ timestamp: Date.now()
104
+ };
105
+ localStorage.setItem('ag_data_cache', JSON.stringify(cacheData));
106
+ } catch (e) {
107
+ if (window.UILogger) window.UILogger.debug('Failed to save cache', e.message);
108
+ }
109
+ },
110
+
111
+ async fetchData() {
112
+ // Only show skeleton on initial load if we didn't restore from cache
113
+ if (this.initialLoad) {
114
+ this.loading = true;
115
+ }
116
+ try {
117
+ // Get password from global store
118
+ const password = Alpine.store('global').webuiPassword;
119
+
120
+ // Include history for dashboard (single API call optimization)
121
+ const url = '/account-limits?includeHistory=true';
122
+ const { response, newPassword } = await window.utils.request(url, {}, password);
123
+
124
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
125
+
126
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
127
+
128
+ const data = await response.json();
129
+ this.accounts = data.accounts || [];
130
+ if (data.models && data.models.length > 0) {
131
+ this.models = data.models;
132
+ }
133
+ this.modelConfig = data.modelConfig || {};
134
+ this.globalQuotaThreshold = data.globalQuotaThreshold || 0;
135
+
136
+ // Store usage history if included (for dashboard)
137
+ if (data.history) {
138
+ this.usageHistory = data.history;
139
+ }
140
+
141
+ this.saveToCache(); // Save fresh data
142
+
143
+ // Re-inject placeholder data if active
144
+ if (this.placeholderMode) {
145
+ this._realAccounts = [...this.accounts];
146
+ this._realModels = [...this.models];
147
+ const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData();
148
+ if (this.placeholderIncludeReal) {
149
+ this.accounts = [...this._realAccounts, ...fakeAccounts];
150
+ const modelSet = new Set([...this._realModels, ...fakeModels]);
151
+ this.models = Array.from(modelSet).sort();
152
+ } else {
153
+ this.accounts = fakeAccounts;
154
+ this.models = fakeModels;
155
+ }
156
+ }
157
+
158
+ this.computeQuotaRows();
159
+
160
+ this.lastUpdated = new Date().toLocaleTimeString();
161
+ } catch (error) {
162
+ // Keep error logging for actual fetch failures
163
+ console.error('Fetch error:', error);
164
+ const store = Alpine.store('global');
165
+ store.showToast(store.t('connectionLost'), 'error');
166
+ } finally {
167
+ this.loading = false;
168
+ this.initialLoad = false; // Mark initial load as complete
169
+ }
170
+ },
171
+
172
+ async performHealthCheck() {
173
+ try {
174
+ // Get password from global store
175
+ const password = Alpine.store('global').webuiPassword;
176
+
177
+ // Use lightweight endpoint (no quota fetching)
178
+ const { response, newPassword } = await window.utils.request('/api/config', {}, password);
179
+
180
+ if (newPassword) Alpine.store('global').webuiPassword = newPassword;
181
+
182
+ if (response.ok) {
183
+ this.connectionStatus = 'connected';
184
+ // Update devMode from server config
185
+ try {
186
+ const data = await response.json();
187
+ if (data.config) {
188
+ this.devMode = !!data.config.devMode;
189
+ }
190
+ } catch (e) { /* ignore parse errors */ }
191
+ } else {
192
+ this.connectionStatus = 'disconnected';
193
+ }
194
+ } catch (error) {
195
+ console.error('Health check error:', error);
196
+ this.connectionStatus = 'disconnected';
197
+ }
198
+ },
199
+
200
+ startHealthCheck() {
201
+ // Clear existing timer
202
+ if (this.healthCheckTimer) {
203
+ clearInterval(this.healthCheckTimer);
204
+ }
205
+
206
+ // Setup visibility change listener (only once)
207
+ if (!this._healthVisibilitySetup) {
208
+ this._healthVisibilitySetup = true;
209
+ this._visibilityHandler = () => {
210
+ if (document.hidden) {
211
+ // Tab hidden - stop health checks
212
+ this.stopHealthCheck();
213
+ } else {
214
+ // Tab visible - restart health checks
215
+ this.startHealthCheck();
216
+ }
217
+ };
218
+ document.addEventListener('visibilitychange', this._visibilityHandler);
219
+ }
220
+
221
+ // Perform immediate health check
222
+ this.performHealthCheck();
223
+
224
+ // Schedule regular health checks every 15 seconds
225
+ this.healthCheckTimer = setInterval(() => {
226
+ // Only perform health check if tab is visible
227
+ if (!document.hidden) {
228
+ this.performHealthCheck();
229
+ }
230
+ }, 15000);
231
+ },
232
+
233
+ stopHealthCheck() {
234
+ if (this.healthCheckTimer) {
235
+ clearInterval(this.healthCheckTimer);
236
+ this.healthCheckTimer = null;
237
+ }
238
+ },
239
+
240
+ computeQuotaRows() {
241
+ const models = this.models || [];
242
+ const rows = [];
243
+ const showExhausted = Alpine.store('settings')?.showExhausted ?? true;
244
+
245
+ models.forEach(modelId => {
246
+ // Config
247
+ const config = this.modelConfig[modelId] || {};
248
+ const family = this.getModelFamily(modelId);
249
+
250
+ // Visibility Logic for Models Page (quotaRows):
251
+ // 1. If explicitly hidden via config, ALWAYS hide (clean interface)
252
+ // 2. If no config, default 'unknown' families to HIDDEN
253
+ // 3. Known families (Claude/Gemini) default to VISIBLE
254
+ // Note: To manage hidden models, use Settings → Models tab
255
+ let isHidden = config.hidden;
256
+ if (isHidden === undefined) {
257
+ isHidden = (family === 'other' || family === 'unknown');
258
+ }
259
+
260
+ // Models Page: Check settings for visibility
261
+ const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
262
+ if (isHidden && !showHidden) return;
263
+
264
+ // Filters
265
+ if (this.filters.family !== 'all' && this.filters.family !== family) return;
266
+ if (this.filters.search) {
267
+ const searchLower = this.filters.search.toLowerCase();
268
+ const idMatch = modelId.toLowerCase().includes(searchLower);
269
+ if (!idMatch) return;
270
+ }
271
+
272
+ // Data Collection
273
+ const quotaInfo = [];
274
+ let minQuota = 100;
275
+ let totalQuotaSum = 0;
276
+ let validAccountCount = 0;
277
+ let minResetTime = null;
278
+ let maxEffectiveThreshold = 0;
279
+ const globalThreshold = this.globalQuotaThreshold || 0;
280
+
281
+ this.accounts.forEach(acc => {
282
+ if (acc.enabled === false) return;
283
+ if (this.filters.account !== 'all' && acc.email !== this.filters.account) return;
284
+
285
+ const limit = acc.limits?.[modelId];
286
+ if (!limit) return;
287
+
288
+ const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
289
+ minQuota = Math.min(minQuota, pct);
290
+
291
+ // Accumulate for average
292
+ totalQuotaSum += pct;
293
+ validAccountCount++;
294
+
295
+ if (limit.resetTime && (!minResetTime || new Date(limit.resetTime) < new Date(minResetTime))) {
296
+ minResetTime = limit.resetTime;
297
+ }
298
+
299
+ // Resolve effective threshold: per-model > per-account > global
300
+ const accModelThreshold = acc.modelQuotaThresholds?.[modelId];
301
+ const accThreshold = acc.quotaThreshold;
302
+ const effective = accModelThreshold ?? accThreshold ?? globalThreshold;
303
+ if (effective > maxEffectiveThreshold) {
304
+ maxEffectiveThreshold = effective;
305
+ }
306
+
307
+ // Determine threshold source for display
308
+ let thresholdSource = 'global';
309
+ if (accModelThreshold !== undefined) thresholdSource = 'model';
310
+ else if (accThreshold !== undefined) thresholdSource = 'account';
311
+
312
+ quotaInfo.push({
313
+ email: acc.email.split('@')[0],
314
+ fullEmail: acc.email,
315
+ pct: pct,
316
+ resetTime: limit.resetTime,
317
+ thresholdPct: Math.round(effective * 100),
318
+ thresholdSource
319
+ });
320
+ });
321
+
322
+ if (quotaInfo.length === 0) return;
323
+ const avgQuota = validAccountCount > 0 ? Math.round(totalQuotaSum / validAccountCount) : 0;
324
+
325
+ if (!showExhausted && minQuota === 0) return;
326
+
327
+ // Check if thresholds vary across accounts
328
+ const uniqueThresholds = new Set(quotaInfo.map(q => q.thresholdPct));
329
+ const hasVariedThresholds = uniqueThresholds.size > 1;
330
+
331
+ rows.push({
332
+ modelId,
333
+ displayName: modelId, // Simplified: no longer using alias
334
+ family,
335
+ minQuota,
336
+ avgQuota, // Added Average Quota
337
+ minResetTime,
338
+ resetIn: minResetTime ? window.utils.formatTimeUntil(minResetTime) : '-',
339
+ quotaInfo,
340
+ pinned: !!config.pinned,
341
+ hidden: !!isHidden, // Use computed visibility
342
+ activeCount: quotaInfo.filter(q => q.pct > 0).length,
343
+ effectiveThresholdPct: Math.round(maxEffectiveThreshold * 100),
344
+ hasVariedThresholds
345
+ });
346
+ });
347
+
348
+ // Sort: Pinned first, then by selected column
349
+ const sortCol = this.filters.sortCol;
350
+ const sortAsc = this.filters.sortAsc;
351
+
352
+ this.quotaRows = rows.sort((a, b) => {
353
+ if (a.pinned !== b.pinned) return a.pinned ? -1 : 1;
354
+
355
+ let valA = a[sortCol];
356
+ let valB = b[sortCol];
357
+
358
+ // Handle nulls (always push to bottom)
359
+ if (valA === valB) return 0;
360
+ if (valA === null || valA === undefined) return 1;
361
+ if (valB === null || valB === undefined) return -1;
362
+
363
+ if (typeof valA === 'string' && typeof valB === 'string') {
364
+ return sortAsc ? valA.localeCompare(valB) : valB.localeCompare(valA);
365
+ }
366
+
367
+ return sortAsc ? valA - valB : valB - valA;
368
+ });
369
+
370
+ // Trigger Dashboard Update if active
371
+ // Ideally dashboard watches this store.
372
+ },
373
+
374
+ setSort(col) {
375
+ if (this.filters.sortCol === col) {
376
+ this.filters.sortAsc = !this.filters.sortAsc;
377
+ } else {
378
+ this.filters.sortCol = col;
379
+ // Default sort direction: Descending for numbers/stats, Ascending for text/time
380
+ if (['avgQuota', 'activeCount'].includes(col)) {
381
+ this.filters.sortAsc = false;
382
+ } else {
383
+ this.filters.sortAsc = true;
384
+ }
385
+ }
386
+ this.computeQuotaRows();
387
+ },
388
+
389
+ getModelFamily(modelId) {
390
+ const lower = modelId.toLowerCase();
391
+ if (lower.includes('claude')) return 'claude';
392
+ if (lower.includes('gemini')) return 'gemini';
393
+ return 'other';
394
+ },
395
+
396
+ /**
397
+ * Get quota data without filters applied (for Dashboard global charts)
398
+ * Returns array of { modelId, family, quotaInfo: [{pct}] }
399
+ */
400
+ getUnfilteredQuotaData() {
401
+ const models = this.models || [];
402
+ const rows = [];
403
+ const showHidden = Alpine.store('settings')?.showHiddenModels ?? false;
404
+
405
+ models.forEach(modelId => {
406
+ const config = this.modelConfig[modelId] || {};
407
+ const family = this.getModelFamily(modelId);
408
+
409
+ // Smart visibility (same logic as computeQuotaRows)
410
+ let isHidden = config.hidden;
411
+ if (isHidden === undefined) {
412
+ isHidden = (family === 'other' || family === 'unknown');
413
+ }
414
+ if (isHidden && !showHidden) return;
415
+
416
+ const quotaInfo = [];
417
+ // Use ALL accounts (no account filter)
418
+ this.accounts.forEach(acc => {
419
+ if (acc.enabled === false) return;
420
+ const limit = acc.limits?.[modelId];
421
+ if (!limit) return;
422
+ const pct = limit.remainingFraction !== null ? Math.round(limit.remainingFraction * 100) : 0;
423
+ quotaInfo.push({ pct });
424
+ });
425
+
426
+ // treat missing quotaInfo as 0%/unknown; still include row
427
+ rows.push({ modelId, family, quotaInfo });
428
+ });
429
+
430
+ return rows;
431
+ },
432
+
433
+ /**
434
+ * Generate placeholder account and model data for UI testing
435
+ */
436
+ _generatePlaceholderData() {
437
+ const models = [
438
+ 'claude-opus-4-6-thinking',
439
+ 'claude-sonnet-4-6',
440
+ 'gemini-3.1-pro-high',
441
+ 'gemini-3.1-pro-low',
442
+ 'gemini-3-flash',
443
+ 'gemini-3.1-flash-lite'
444
+ ];
445
+
446
+ const tiers = ['ultra', 'pro', 'pro', 'free'];
447
+ const names = ['alice', 'bob', 'charlie', 'diana'];
448
+ const domains = ['workspace.dev', 'company.io', 'example.org', 'test.net'];
449
+
450
+ const accounts = names.map((name, i) => {
451
+ const email = `${name}@${domains[i]}`;
452
+ const tier = tiers[i];
453
+
454
+ // Generate varied quota per model per account
455
+ const limits = {};
456
+ models.forEach((modelId, mi) => {
457
+ // Create a deterministic but varied fraction
458
+ const seed = ((i * 7 + mi * 13) % 100);
459
+ const fraction = seed < 10 ? 0 : seed / 100;
460
+ const resetTime = fraction === 0
461
+ ? new Date(Date.now() + (30 + i * 15) * 60000).toISOString()
462
+ : null;
463
+ limits[modelId] = {
464
+ remaining: Math.round(fraction * 100) + '%',
465
+ remainingFraction: fraction,
466
+ resetTime
467
+ };
468
+ });
469
+
470
+ return {
471
+ email,
472
+ status: i === 3 ? 'invalid' : 'ok',
473
+ error: i === 3 ? 'Token expired' : null,
474
+ source: i === 0 ? 'database' : 'oauth',
475
+ enabled: i !== 2 ? true : false,
476
+ projectId: `proj-${name}-${1000 + i}`,
477
+ isInvalid: i === 3,
478
+ invalidReason: i === 3 ? 'Token expired' : null,
479
+ lastUsed: new Date(Date.now() - i * 3600000).toISOString(),
480
+ modelRateLimits: {},
481
+ quotaThreshold: i === 1 ? 0.15 : undefined,
482
+ modelQuotaThresholds: i === 0 ? { 'claude-opus-4-6-thinking': 0.25 } : {},
483
+ subscription: { tier, projectId: `proj-${name}-${1000 + i}`, detectedAt: Date.now() },
484
+ limits
485
+ };
486
+ });
487
+
488
+ return { accounts, models };
489
+ },
490
+
491
+ /**
492
+ * Enable or disable placeholder data injection
493
+ */
494
+ setPlaceholderMode(enabled, includeReal) {
495
+ this.placeholderMode = enabled;
496
+ this.placeholderIncludeReal = includeReal;
497
+
498
+ // Persist to settings store
499
+ const settings = Alpine.store('settings');
500
+ if (settings) {
501
+ settings.placeholderMode = enabled;
502
+ settings.placeholderIncludeReal = includeReal;
503
+ settings.saveSettings(true);
504
+ }
505
+
506
+ if (enabled) {
507
+ // Stash real data
508
+ this._realAccounts = [...this.accounts];
509
+ this._realModels = [...this.models];
510
+
511
+ const { accounts: fakeAccounts, models: fakeModels } = this._generatePlaceholderData();
512
+
513
+ if (includeReal && this._realAccounts.length > 0) {
514
+ // Merge: real accounts first, then placeholders
515
+ this.accounts = [...this._realAccounts, ...fakeAccounts];
516
+ // Union of models
517
+ const modelSet = new Set([...this._realModels, ...fakeModels]);
518
+ this.models = Array.from(modelSet).sort();
519
+ } else {
520
+ this.accounts = fakeAccounts;
521
+ this.models = fakeModels;
522
+ }
523
+ } else {
524
+ // Restore real data
525
+ if (this._realAccounts !== null) {
526
+ this.accounts = this._realAccounts;
527
+ this._realAccounts = null;
528
+ }
529
+ if (this._realModels !== null) {
530
+ this.models = this._realModels;
531
+ this._realModels = null;
532
+ }
533
+ }
534
+
535
+ this.computeQuotaRows();
536
+ }
537
+ });
538
+ });
public/js/settings-store.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * Settings Store
3
+ */
4
+ document.addEventListener('alpine:init', () => {
5
+ Alpine.store('settings', {
6
+ refreshInterval: 60,
7
+ logLimit: 2000,
8
+ showExhausted: true,
9
+ showHiddenModels: false,
10
+ showAllAccounts: false,
11
+ showConfigWarning: true,
12
+ compact: false,
13
+ redactMode: false,
14
+ placeholderMode: false,
15
+ placeholderIncludeReal: true,
16
+ debugLogging: true,
17
+ logExport: true,
18
+ healthInspector: true,
19
+ healthInspectorOpen: false,
20
+ port: 8080, // Display only
21
+
22
+ init() {
23
+ this.loadSettings();
24
+ },
25
+
26
+ // Call this method when toggling settings in the UI
27
+ toggle(key) {
28
+ if (this.hasOwnProperty(key) && typeof this[key] === 'boolean') {
29
+ this[key] = !this[key];
30
+ this.saveSettings(true);
31
+ }
32
+ },
33
+
34
+ loadSettings() {
35
+ const saved = localStorage.getItem('antigravity_settings');
36
+ if (saved) {
37
+ const parsed = JSON.parse(saved);
38
+ Object.keys(parsed).forEach(k => {
39
+ // Only load keys that exist in our default state (safety)
40
+ if (this.hasOwnProperty(k)) this[k] = parsed[k];
41
+ });
42
+ }
43
+ },
44
+
45
+ saveSettings(silent = false) {
46
+ const toSave = {
47
+ refreshInterval: this.refreshInterval,
48
+ logLimit: this.logLimit,
49
+ showExhausted: this.showExhausted,
50
+ showHiddenModels: this.showHiddenModels,
51
+ showAllAccounts: this.showAllAccounts,
52
+ showConfigWarning: this.showConfigWarning,
53
+ compact: this.compact,
54
+ redactMode: this.redactMode,
55
+ placeholderMode: this.placeholderMode,
56
+ placeholderIncludeReal: this.placeholderIncludeReal,
57
+ debugLogging: this.debugLogging,
58
+ logExport: this.logExport,
59
+ healthInspector: this.healthInspector,
60
+ healthInspectorOpen: this.healthInspectorOpen
61
+ };
62
+ localStorage.setItem('antigravity_settings', JSON.stringify(toSave));
63
+
64
+ if (!silent) {
65
+ const store = Alpine.store('global');
66
+ store.showToast(store.t('configSaved'), 'success');
67
+ }
68
+
69
+ // Trigger updates
70
+ document.dispatchEvent(new CustomEvent('refresh-interval-changed'));
71
+ if (Alpine.store('data')) {
72
+ Alpine.store('data').computeQuotaRows();
73
+ }
74
+ }
75
+ });
76
+ });