Spaces:
Running
Running
Commit ·
4badc3b
0
Parent(s):
Fix gemini-3.1-pro-high by routing to pro-low with thinkingLevel high.
Browse filesThe 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
- .dockerignore +12 -0
- .gitattributes +2 -0
- .github/FUNDING.yml +1 -0
- .github/ISSUE_TEMPLATE/bug_report.md +53 -0
- .github/ISSUE_TEMPLATE/config.yml +14 -0
- .github/ISSUE_TEMPLATE/feature_request.md +28 -0
- .github/workflows/publish.yml +29 -0
- .gitignore +36 -0
- .npmignore +21 -0
- CLAUDE.md +51 -0
- Dockerfile +42 -0
- LICENSE +21 -0
- README.md +52 -0
- bin/cli.js +490 -0
- config.example.json +60 -0
- docs/api-endpoints.md +9 -0
- docs/configuration.md +94 -0
- docs/development.md +54 -0
- docs/legal.md +9 -0
- docs/load-balancing.md +59 -0
- docs/menubar-app.md +17 -0
- docs/models.md +19 -0
- docs/openclaw.md +159 -0
- docs/opencode.md +150 -0
- docs/safety-notices.md +27 -0
- docs/testing.md +24 -0
- docs/troubleshooting.md +145 -0
- docs/web-console.md +20 -0
- package-lock.json +0 -0
- package.json +78 -0
- postcss.config.js +6 -0
- public/app.js +227 -0
- public/css/src/input.css +523 -0
- public/css/style.css +0 -0
- public/favicon.svg +10 -0
- public/index.html +434 -0
- public/js/components/account-manager.js +502 -0
- public/js/components/add-account-modal.js +88 -0
- public/js/components/claude-config.js +508 -0
- public/js/components/dashboard.js +323 -0
- public/js/components/dashboard/charts.js +590 -0
- public/js/components/dashboard/filters.js +362 -0
- public/js/components/dashboard/stats.js +110 -0
- public/js/components/logs-viewer.js +131 -0
- public/js/components/model-dropdown.js +151 -0
- public/js/components/models.js +301 -0
- public/js/components/server-config.js +1203 -0
- public/js/config/constants.js +165 -0
- public/js/data-store.js +538 -0
- 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 |
+

|
| 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 |
+

|
| 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 |
+
});
|