feat(webui): enhance settings UI, persistence and documentation

- Update CLAUDE.md with comprehensive WebUI architecture and API documentation
- Improve settings UI with searchable model dropdowns and visual family indicators
- Migrate usage statistics persistence to user config directory with auto-migration
- Refactor server request handling and fix model suffix logic
This commit is contained in:
Wha1eChai
2026-01-10 04:22:59 +08:00
parent 98685241e8
commit 71c7c2e423
6 changed files with 294 additions and 109 deletions

View File

@@ -87,6 +87,9 @@ src/
│ ├── token-extractor.js # Legacy token extraction from DB │ ├── token-extractor.js # Legacy token extraction from DB
│ └── database.js # SQLite database access │ └── database.js # SQLite database access
├── webui/ # Web Management Interface
│ └── index.js # Express router and API endpoints
├── cli/ # CLI tools ├── cli/ # CLI tools
│ └── accounts.js # Account management CLI │ └── accounts.js # Account management CLI
@@ -105,9 +108,31 @@ src/
└── native-module-helper.js # Auto-rebuild for native modules └── native-module-helper.js # Auto-rebuild for native modules
``` ```
**Frontend Structure (public/):**
```
public/
├── index.html # Main entry point
├── js/
│ ├── app.js # Main application logic (Alpine.js)
│ ├── store.js # Global state management
│ ├── components/ # UI Components
│ │ ├── dashboard.js # Real-time stats & charts
│ │ ├── account-manager.js # Account list & OAuth handling
│ │ ├── logs-viewer.js # Live log streaming
│ │ └── claude-config.js # CLI settings editor
│ └── utils/ # Frontend utilities
└── views/ # HTML partials (loaded dynamically)
├── dashboard.html
├── accounts.html
├── settings.html
└── logs.html
```
**Key Modules:** **Key Modules:**
- **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) - **src/server.js**: Express server exposing Anthropic-compatible endpoints (`/v1/messages`, `/v1/models`, `/health`, `/account-limits`) and mounting WebUI
- **src/webui/index.js**: WebUI backend handling API routes (`/api/*`) for config, accounts, and logs
- **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support - **src/cloudcode/**: Cloud Code API client with retry/failover logic, streaming and non-streaming support
- **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown - **src/account-manager/**: Multi-account pool with sticky selection, rate limit handling, and automatic cooldown
- **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules - **src/auth/**: Authentication including Google OAuth, token extraction, database access, and auto-rebuild of native modules
@@ -152,6 +177,18 @@ src/
- If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required - If rebuild succeeds, the module is reloaded; if reload fails, a server restart is required
- Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js` - Implementation in `src/utils/native-module-helper.js` and lazy loading in `src/auth/database.js`
**Web Management UI:**
- **Stack**: Vanilla JS + Alpine.js + Tailwind CSS (via CDN)
- **Architecture**: Single Page Application (SPA) with dynamic view loading
- **State Management**: Alpine.store for global state (accounts, settings, logs)
- **Features**:
- Real-time dashboard with Chart.js visualization
- OAuth flow handling via popup window
- Live log streaming via Server-Sent Events (SSE)
- Config editor for both Proxy and Claude CLI (`~/.claude/settings.json`)
- **Security**: Optional password protection via `WEBUI_PASSWORD` env var
## Testing Notes ## Testing Notes
- Tests require the server to be running (`npm start` in separate terminal) - Tests require the server to be running (`npm start` in separate terminal)
@@ -195,6 +232,14 @@ src/
- `logger.setDebug(true)` - Enable debug mode - `logger.setDebug(true)` - Enable debug mode
- `logger.isDebugEnabled` - Check if debug mode is on - `logger.isDebugEnabled` - Check if debug mode is on
**WebUI APIs:**
- `/api/accounts/*` - Account management (list, add, remove, refresh)
- `/api/config/*` - Server configuration (read/write)
- `/api/claude/config` - Claude CLI settings
- `/api/logs/stream` - SSE endpoint for real-time logs
- `/api/auth/url` - Generate Google OAuth URL
## Maintenance ## Maintenance
When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync. When making significant changes to the codebase (new modules, refactoring, architectural changes), update this CLAUDE.md and the README.md file to keep documentation in sync.

View File

@@ -57,7 +57,8 @@ window.Components.claudeConfig = () => ({
toggleGemini1mSuffix(enabled) { toggleGemini1mSuffix(enabled) {
for (const field of this.geminiModelFields) { for (const field of this.geminiModelFields) {
const val = this.config.env[field]; const val = this.config.env[field];
if (val && val.toLowerCase().includes('gemini')) { // Fix: Case-insensitive check for gemini
if (val && /gemini/i.test(val)) {
if (enabled && !val.includes('[1m]')) { if (enabled && !val.includes('[1m]')) {
this.config.env[field] = val.trim() + ' [1m]'; this.config.env[field] = val.trim() + ' [1m]';
} else if (!enabled && val.includes('[1m]')) { } else if (!enabled && val.includes('[1m]')) {
@@ -68,6 +69,25 @@ window.Components.claudeConfig = () => ({
this.gemini1mSuffix = enabled; this.gemini1mSuffix = enabled;
}, },
/**
* Helper to select a model from the dropdown
* @param {string} field - The config.env field to update
* @param {string} modelId - The selected model ID
*/
selectModel(field, modelId) {
if (!this.config.env) this.config.env = {};
let finalModelId = modelId;
// If 1M mode is enabled and it's a Gemini model, append the suffix
if (this.gemini1mSuffix && modelId.toLowerCase().includes('gemini')) {
if (!finalModelId.includes('[1m]')) {
finalModelId = finalModelId.trim() + ' [1m]';
}
}
this.config.env[field] = finalModelId;
},
async fetchConfig() { async fetchConfig() {
const password = Alpine.store('global').webuiPassword; const password = Alpine.store('global').webuiPassword;
try { try {

View File

@@ -99,11 +99,13 @@
<input type="range" min="10" max="300" class="custom-range custom-range-purple flex-1" <input type="range" min="10" max="300" class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.refreshInterval" x-model.number="$store.settings.refreshInterval"
:style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`" :style="`background-size: ${($store.settings.refreshInterval - 10) / 2.9}% 100%`"
@change="$store.settings.saveSettings(true)"> @change="$store.settings.saveSettings(true)"
aria-label="Polling interval slider">
<input type="number" min="10" max="300" <input type="number" min="10" max="300"
class="input input-sm input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center" class="input input-sm input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
x-model.number="$store.settings.refreshInterval" x-model.number="$store.settings.refreshInterval"
@change="$store.settings.saveSettings(true)"> @change="$store.settings.saveSettings(true)"
aria-label="Polling interval value">
</div> </div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono"> <div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>10s</span> <span>10s</span>
@@ -123,11 +125,13 @@
<input type="range" min="500" max="5000" step="500" class="custom-range custom-range-purple flex-1" <input type="range" min="500" max="5000" step="500" class="custom-range custom-range-purple flex-1"
x-model.number="$store.settings.logLimit" x-model.number="$store.settings.logLimit"
:style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`" :style="`background-size: ${($store.settings.logLimit - 500) / 45}% 100%`"
@change="$store.settings.saveSettings(true)"> @change="$store.settings.saveSettings(true)"
aria-label="Log buffer size slider">
<input type="number" min="500" max="5000" step="500" <input type="number" min="500" max="5000" step="500"
class="input input-sm input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center" class="input input-sm input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
x-model.number="$store.settings.logLimit" x-model.number="$store.settings.logLimit"
@change="$store.settings.saveSettings(true)"> @change="$store.settings.saveSettings(true)"
aria-label="Log buffer size value">
</div> </div>
<div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono"> <div class="w-full flex justify-between text-xs px-2 mt-2 text-gray-600 font-mono">
<span>500</span> <span>500</span>
@@ -153,7 +157,8 @@
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="$store.settings.showExhausted === true" :checked="$store.settings.showExhausted === true"
@change="$store.settings.showExhausted = $event.target.checked; $store.settings.saveSettings(true)"> @change="$store.settings.showExhausted = $event.target.checked; $store.settings.saveSettings(true)"
aria-label="Show exhausted models toggle">
<div <div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white"> class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white">
</div> </div>
@@ -173,7 +178,8 @@
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" :checked="$store.settings.compact === true" <input type="checkbox" class="sr-only peer" :checked="$store.settings.compact === true"
@change="$store.settings.compact = $event.target.checked; $store.settings.saveSettings(true)"> @change="$store.settings.compact = $event.target.checked; $store.settings.saveSettings(true)"
aria-label="Compact mode toggle">
<div <div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white"> class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
</div> </div>
@@ -225,13 +231,18 @@
<div class="form-control"> <div class="form-control">
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider" <label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider"
x-text="$store.global.t('primaryModel')">Primary Model</label> x-text="$store.global.t('primaryModel')">Primary Model</label>
<div class="relative w-full" x-data="{ open: false }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" x-model="config.env.ANTHROPIC_MODEL" @focus="open = true" <input type="text"
@click.away="open = false" :value="open ? searchTerm : config.env.ANTHROPIC_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600" class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="$store.global.t('typeToSearch')"> :placeholder="open ? $store.global.t('typeToSearch') : ''"
:class="{ '!text-gray-500': !open && !config.env.ANTHROPIC_MODEL }"
aria-label="Primary model selection">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" <div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors"
@click="open = !open; if(open) $el.previousElementSibling.focus()" @click="open = !open; if(open) { searchTerm = ''; $el.previousElementSibling.focus() }"
@mousedown.prevent></div> @mousedown.prevent></div>
<ul x-show="open" x-transition:enter="transition ease-out duration-100" <ul x-show="open" x-transition:enter="transition ease-out duration-100"
@@ -239,20 +250,24 @@
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar"> class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template <template
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_MODEL?.toLowerCase() || ''))" x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
:key="modelId"> :key="modelId">
<li> <li>
<a @mousedown.prevent="config.env.ANTHROPIC_MODEL = modelId; open = false" <a @mousedown.prevent="selectModel('ANTHROPIC_MODEL', modelId); open = false; searchTerm = ''"
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center gap-2" class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
:class="config.env.ANTHROPIC_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'"> :class="config.env.ANTHROPIC_MODEL === modelId || config.env.ANTHROPIC_MODEL === modelId + ' [1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" <span class="w-1.5 h-1.5 rounded-full"
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span> <span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a> </a>
</li> </li>
</template> </template>
<li <li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
x-show="$store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_MODEL?.toLowerCase() || '')).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -265,13 +280,18 @@
<div class="form-control"> <div class="form-control">
<label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider" <label class="label pt-0 pb-1 text-[11px] text-gray-400 font-bold tracking-wider"
x-text="$store.global.t('subAgentModel')">Sub-agent Model</label> x-text="$store.global.t('subAgentModel')">Sub-agent Model</label>
<div class="relative w-full" x-data="{ open: false }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" x-model="config.env.CLAUDE_CODE_SUBAGENT_MODEL" @focus="open = true" <input type="text"
@click.away="open = false" :value="open ? searchTerm : config.env.CLAUDE_CODE_SUBAGENT_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-purple pr-8 placeholder:!text-gray-600" class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-purple pr-8 placeholder:!text-gray-600"
:placeholder="$store.global.t('typeToSearch')"> :placeholder="open ? $store.global.t('typeToSearch') : ''"
:class="{ '!text-gray-500': !open && !config.env.CLAUDE_CODE_SUBAGENT_MODEL }"
aria-label="Sub-agent model selection">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" <div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors"
@click="open = !open; if(open) $el.previousElementSibling.focus()" @click="open = !open; if(open) { searchTerm = ''; $el.previousElementSibling.focus() }"
@mousedown.prevent></div> @mousedown.prevent></div>
<ul x-show="open" x-transition:enter="transition ease-out duration-100" <ul x-show="open" x-transition:enter="transition ease-out duration-100"
@@ -279,20 +299,24 @@
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar"> class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template <template
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.CLAUDE_CODE_SUBAGENT_MODEL?.toLowerCase() || ''))" x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
:key="modelId"> :key="modelId">
<li> <li>
<a @mousedown.prevent="config.env.CLAUDE_CODE_SUBAGENT_MODEL = modelId; open = false" <a @mousedown.prevent="selectModel('CLAUDE_CODE_SUBAGENT_MODEL', modelId); open = false; searchTerm = ''"
class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center gap-2" class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
:class="config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId ? 'text-neon-purple bg-space-800/50' : 'text-gray-300'"> :class="config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId || config.env.CLAUDE_CODE_SUBAGENT_MODEL === modelId + ' [1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" <span class="w-1.5 h-1.5 rounded-full"
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span> <span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a> </a>
</li> </li>
</template> </template>
<li <li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
x-show="$store.data.models.filter(m => m.toLowerCase().includes(config.env.CLAUDE_CODE_SUBAGENT_MODEL?.toLowerCase() || '')).length === 0">
<span class="text-xs text-gray-500 italic py-2" <span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span> x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li> </li>
@@ -311,31 +335,45 @@
<div class="form-control"> <div class="form-control">
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold" <label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('opusAlias')">Opus Alias</label> x-text="$store.global.t('opusAlias')">Opus Alias</label>
<div class="relative w-full" x-data="{ open: false }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL" <input type="text"
@focus="open = true" @click.away="open = false" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_OPUS_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600" class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="$store.global.t('searchPlaceholder')"> :placeholder="open ? $store.global.t('searchPlaceholder') : ''"
:class="{ '!text-gray-500': !open && !config.env.ANTHROPIC_DEFAULT_OPUS_MODEL }"
aria-label="Opus model alias selection">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" <div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors"
@click="open = !open; if(open) $el.previousElementSibling.focus()" @click="open = !open; if(open) { searchTerm = ''; $el.previousElementSibling.focus() }"
@mousedown.prevent></div> @mousedown.prevent></div>
<ul x-show="open" x-transition:enter="transition ease-out duration-100" <ul x-show="open" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar"> class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template <template
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL?.toLowerCase() || ''))" x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
:key="modelId"> :key="modelId">
<li> <li>
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL = modelId; open = false" <a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_OPUS_MODEL', modelId); open = false; searchTerm = ''"
class="font-mono text-xs py-1 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center gap-2" class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
:class="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'"> :class="config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_OPUS_MODEL === modelId + ' [1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" <span class="w-1.5 h-1.5 rounded-full"
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span> <span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -343,31 +381,45 @@
<div class="form-control"> <div class="form-control">
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold" <label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('sonnetAlias')">Sonnet Alias</label> x-text="$store.global.t('sonnetAlias')">Sonnet Alias</label>
<div class="relative w-full" x-data="{ open: false }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL" <input type="text"
@focus="open = true" @click.away="open = false" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_SONNET_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600" class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="$store.global.t('searchPlaceholder')"> :placeholder="open ? $store.global.t('searchPlaceholder') : ''"
:class="{ '!text-gray-500': !open && !config.env.ANTHROPIC_DEFAULT_SONNET_MODEL }"
aria-label="Sonnet model alias selection">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" <div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors"
@click="open = !open; if(open) $el.previousElementSibling.focus()" @click="open = !open; if(open) { searchTerm = ''; $el.previousElementSibling.focus() }"
@mousedown.prevent></div> @mousedown.prevent></div>
<ul x-show="open" x-transition:enter="transition ease-out duration-100" <ul x-show="open" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar"> class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template <template
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL?.toLowerCase() || ''))" x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
:key="modelId"> :key="modelId">
<li> <li>
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL = modelId; open = false" <a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_SONNET_MODEL', modelId); open = false; searchTerm = ''"
class="font-mono text-xs py-1 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center gap-2" class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
:class="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'"> :class="config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_SONNET_MODEL === modelId + ' [1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" <span class="w-1.5 h-1.5 rounded-full"
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span> <span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -375,31 +427,45 @@
<div class="form-control"> <div class="form-control">
<label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold" <label class="label pt-0 pb-1 text-[10px] text-gray-500 uppercase font-bold"
x-text="$store.global.t('haikuAlias')">Haiku Alias</label> x-text="$store.global.t('haikuAlias')">Haiku Alias</label>
<div class="relative w-full" x-data="{ open: false }"> <div class="relative w-full" x-data="{ open: false, searchTerm: '' }">
<input type="text" x-model="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL" <input type="text"
@focus="open = true" @click.away="open = false" :value="open ? searchTerm : config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL"
@input="searchTerm = $event.target.value"
@focus="open = true; searchTerm = ''"
@click.away="open = false; searchTerm = ''"
class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600" class="input input-sm w-full font-mono text-xs !bg-space-800 !border-space-border !text-white focus:!bg-space-800 focus:!border-neon-cyan pr-8 placeholder:!text-gray-600"
:placeholder="$store.global.t('searchPlaceholder')"> :placeholder="open ? $store.global.t('searchPlaceholder') : ''"
:class="{ '!text-gray-500': !open && !config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL }"
aria-label="Haiku model alias selection">
<div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors" <div class="absolute right-2 top-1.5 cursor-pointer text-gray-500 hover:text-white transition-colors"
@click="open = !open; if(open) $el.previousElementSibling.focus()" @click="open = !open; if(open) { searchTerm = ''; $el.previousElementSibling.focus() }"
@mousedown.prevent></div> @mousedown.prevent></div>
<ul x-show="open" x-transition:enter="transition ease-out duration-100" <ul x-show="open" x-transition:enter="transition ease-out duration-100"
x-transition:enter-start="opacity-0 scale-95" x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100" x-transition:enter-end="opacity-100 scale-100"
class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar"> class="absolute left-0 right-0 top-full mt-1 menu p-2 shadow-2xl bg-space-900 border border-space-border rounded-lg max-h-60 overflow-y-auto z-[100] custom-scrollbar">
<template <template
x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL?.toLowerCase() || ''))" x-for="modelId in $store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase()))"
:key="modelId"> :key="modelId">
<li> <li>
<a @mousedown.prevent="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = modelId; open = false" <a @mousedown.prevent="selectModel('ANTHROPIC_DEFAULT_HAIKU_MODEL', modelId); open = false; searchTerm = ''"
class="font-mono text-xs py-1 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center gap-2" class="font-mono text-xs py-2 hover:bg-space-800 border-b border-space-border/30 last:border-0 flex items-center justify-between gap-2"
:class="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'"> :class="config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId || config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL === modelId + ' [1m]' ? 'text-neon-cyan bg-space-800/50' : 'text-gray-300'">
<div class="flex items-center gap-2">
<span class="w-1.5 h-1.5 rounded-full" <span class="w-1.5 h-1.5 rounded-full"
:class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span> :class="$store.data.getModelFamily(modelId) === 'claude' ? 'bg-neon-purple shadow-[0_0_5px_rgba(168,85,247,0.5)]' : ($store.data.getModelFamily(modelId) === 'gemini' ? 'bg-neon-green shadow-[0_0_5px_rgba(34,197,94,0.5)]' : 'bg-gray-600')"></span>
<span x-text="modelId"></span> <span x-text="modelId"></span>
</div>
<template x-if="gemini1mSuffix && $store.data.getModelFamily(modelId) === 'gemini'">
<span class="text-[10px] bg-neon-green/10 text-neon-green px-1.5 py-0.5 rounded border border-neon-green/20 font-bold uppercase tracking-tighter">[1M]</span>
</template>
</a> </a>
</li> </li>
</template> </template>
<li x-show="$store.data.models.filter(m => m.toLowerCase().includes(searchTerm.toLowerCase())).length === 0">
<span class="text-xs text-gray-500 italic py-2"
x-text="$store.global.t('noMatchingModels')">No matching models</span>
</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -420,7 +486,8 @@
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="config.env.ENABLE_EXPERIMENTAL_MCP_CLI === 'true'" :checked="config.env.ENABLE_EXPERIMENTAL_MCP_CLI === 'true'"
@change="config.env.ENABLE_EXPERIMENTAL_MCP_CLI = $event.target.checked ? 'true' : 'false'"> @change="config.env.ENABLE_EXPERIMENTAL_MCP_CLI = $event.target.checked ? 'true' : 'false'"
aria-label="Experimental MCP CLI toggle">
<div <div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white"> class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
</div> </div>
@@ -445,7 +512,8 @@
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="gemini1mSuffix" :checked="gemini1mSuffix"
@change="toggleGemini1mSuffix($event.target.checked)"> @change="toggleGemini1mSuffix($event.target.checked)"
aria-label="Gemini 1M context mode toggle">
<div <div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white"> class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
</div> </div>
@@ -480,7 +548,8 @@
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="$store.settings.showHiddenModels === true" :checked="$store.settings.showHiddenModels === true"
@change="$store.settings.showHiddenModels = $event.target.checked; $store.settings.saveSettings(true)"> @change="$store.settings.showHiddenModels = $event.target.checked; $store.settings.saveSettings(true)"
aria-label="Show hidden models toggle">
<div <div
class="w-8 h-[18px] bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white"> class="w-8 h-[18px] bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-3.5 after:w-3.5 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white">
</div> </div>
@@ -683,7 +752,8 @@
</div> </div>
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" :checked="serverConfig.debug === true" <input type="checkbox" class="sr-only peer" :checked="serverConfig.debug === true"
@change="toggleDebug($el.checked)"> @change="toggleDebug($el.checked)"
aria-label="Debug mode toggle">
<div <div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white"> class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-purple peer-checked:after:bg-white">
</div> </div>
@@ -706,7 +776,8 @@
<label class="relative inline-flex items-center cursor-pointer"> <label class="relative inline-flex items-center cursor-pointer">
<input type="checkbox" class="sr-only peer" <input type="checkbox" class="sr-only peer"
:checked="serverConfig.persistTokenCache === true" :checked="serverConfig.persistTokenCache === true"
@change="toggleTokenCache($el.checked)"> @change="toggleTokenCache($el.checked)"
aria-label="Persist token cache toggle">
<div <div
class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white"> class="w-9 h-5 bg-space-800 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-gray-600 after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-neon-green peer-checked:after:bg-white">
</div> </div>
@@ -763,11 +834,13 @@
<input type="range" min="1" max="20" class="custom-range custom-range-purple flex-1" <input type="range" min="1" max="20" class="custom-range custom-range-purple flex-1"
:value="serverConfig.maxRetries || 5" :value="serverConfig.maxRetries || 5"
:style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`" :style="`background-size: ${((serverConfig.maxRetries || 5) - 1) / 19 * 100}% 100%`"
@input="toggleMaxRetries($event.target.value)"> @input="toggleMaxRetries($event.target.value)"
aria-label="Max retries slider">
<input type="number" min="1" max="20" <input type="number" min="1" max="20"
class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center" class="input input-xs input-bordered w-16 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxRetries || 5" :value="serverConfig.maxRetries || 5"
@change="toggleMaxRetries($event.target.value)"> @change="toggleMaxRetries($event.target.value)"
aria-label="Max retries value">
</div> </div>
</div> </div>
@@ -784,11 +857,13 @@
class="custom-range custom-range-green flex-1" class="custom-range custom-range-green flex-1"
:value="serverConfig.retryBaseMs || 1000" :value="serverConfig.retryBaseMs || 1000"
:style="`background-size: ${((serverConfig.retryBaseMs || 1000) - 100) / 99}% 100%`" :style="`background-size: ${((serverConfig.retryBaseMs || 1000) - 100) / 99}% 100%`"
@input="toggleRetryBaseMs($event.target.value)"> @input="toggleRetryBaseMs($event.target.value)"
aria-label="Retry base delay slider">
<input type="number" min="100" max="10000" step="100" <input type="number" min="100" max="10000" step="100"
class="input input-xs input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center" class="input input-xs input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.retryBaseMs || 1000" :value="serverConfig.retryBaseMs || 1000"
@change="toggleRetryBaseMs($event.target.value)"> @change="toggleRetryBaseMs($event.target.value)"
aria-label="Retry base delay value">
</div> </div>
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -803,11 +878,13 @@
class="custom-range custom-range-green flex-1" class="custom-range custom-range-green flex-1"
:value="serverConfig.retryMaxMs || 30000" :value="serverConfig.retryMaxMs || 30000"
:style="`background-size: ${((serverConfig.retryMaxMs || 30000) - 1000) / 1190}% 100%`" :style="`background-size: ${((serverConfig.retryMaxMs || 30000) - 1000) / 1190}% 100%`"
@input="toggleRetryMaxMs($event.target.value)"> @input="toggleRetryMaxMs($event.target.value)"
aria-label="Retry max delay slider">
<input type="number" min="1000" max="120000" step="1000" <input type="number" min="1000" max="120000" step="1000"
class="input input-xs input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center" class="input input-xs input-bordered w-20 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.retryMaxMs || 30000" :value="serverConfig.retryMaxMs || 30000"
@change="toggleRetryMaxMs($event.target.value)"> @change="toggleRetryMaxMs($event.target.value)"
aria-label="Retry max delay value">
</div> </div>
</div> </div>
</div> </div>
@@ -832,11 +909,13 @@
class="custom-range custom-range-cyan flex-1" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.defaultCooldownMs || 60000" :value="serverConfig.defaultCooldownMs || 60000"
:style="`background-size: ${((serverConfig.defaultCooldownMs || 60000) - 1000) / 2990}% 100%`" :style="`background-size: ${((serverConfig.defaultCooldownMs || 60000) - 1000) / 2990}% 100%`"
@input="toggleDefaultCooldownMs($event.target.value)"> @input="toggleDefaultCooldownMs($event.target.value)"
aria-label="Default cooldown slider">
<input type="number" min="1000" max="300000" step="1000" <input type="number" min="1000" max="300000" step="1000"
class="input input-xs input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center" class="input input-xs input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.defaultCooldownMs || 60000" :value="serverConfig.defaultCooldownMs || 60000"
@change="toggleDefaultCooldownMs($event.target.value)"> @change="toggleDefaultCooldownMs($event.target.value)"
aria-label="Default cooldown value">
</div> </div>
</div> </div>
@@ -852,11 +931,13 @@
class="custom-range custom-range-cyan flex-1" class="custom-range custom-range-cyan flex-1"
:value="serverConfig.maxWaitBeforeErrorMs || 120000" :value="serverConfig.maxWaitBeforeErrorMs || 120000"
:style="`background-size: ${(serverConfig.maxWaitBeforeErrorMs || 120000) / 6000}% 100%`" :style="`background-size: ${(serverConfig.maxWaitBeforeErrorMs || 120000) / 6000}% 100%`"
@input="toggleMaxWaitBeforeErrorMs($event.target.value)"> @input="toggleMaxWaitBeforeErrorMs($event.target.value)"
aria-label="Max wait threshold slider">
<input type="number" min="0" max="600000" step="10000" <input type="number" min="0" max="600000" step="10000"
class="input input-xs input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center" class="input input-xs input-bordered w-24 bg-space-800 border-space-border text-white font-mono text-center"
:value="serverConfig.maxWaitBeforeErrorMs || 120000" :value="serverConfig.maxWaitBeforeErrorMs || 120000"
@change="toggleMaxWaitBeforeErrorMs($event.target.value)"> @change="toggleMaxWaitBeforeErrorMs($event.target.value)"
aria-label="Max wait threshold value">
</div> </div>
<p class="text-[9px] text-gray-600 mt-1 leading-tight" <p class="text-[9px] text-gray-600 mt-1 leading-tight"
x-text="$store.global.t('maxWaitDesc')">Maximum time to wait for a sticky account to x-text="$store.global.t('maxWaitDesc')">Maximum time to wait for a sticky account to
@@ -895,7 +976,8 @@
</label> </label>
<input type="password" x-model="passwordDialog.oldPassword" <input type="password" x-model="passwordDialog.oldPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordEmptyDesc')"> :placeholder="$store.global.t('passwordEmptyDesc')"
aria-label="Current password">
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -905,7 +987,8 @@
</label> </label>
<input type="password" x-model="passwordDialog.newPassword" <input type="password" x-model="passwordDialog.newPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordLengthDesc')"> :placeholder="$store.global.t('passwordLengthDesc')"
aria-label="New password">
</div> </div>
<div class="form-control"> <div class="form-control">
@@ -916,7 +999,8 @@
<input type="password" x-model="passwordDialog.confirmPassword" <input type="password" x-model="passwordDialog.confirmPassword"
class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full" class="input input-sm input-bordered bg-space-800 border-space-border text-white w-full"
:placeholder="$store.global.t('passwordConfirmDesc')" :placeholder="$store.global.t('passwordConfirmDesc')"
@keydown.enter="changePassword()"> @keydown.enter="changePassword()"
aria-label="Confirm new password">
</div> </div>
</div> </div>
@@ -934,3 +1018,4 @@
</div> </div>
</div> </div>
</div>

View File

@@ -72,6 +72,12 @@ export const ACCOUNT_CONFIG_PATH = config?.accountConfigPath || join(
'.config/antigravity-proxy/accounts.json' '.config/antigravity-proxy/accounts.json'
); );
// Usage history persistence path
export const USAGE_HISTORY_PATH = join(
homedir(),
'.config/antigravity-proxy/usage-history.json'
);
// Antigravity app database path (for legacy single-account token extraction) // Antigravity app database path (for legacy single-account token extraction)
// Uses platform-specific path detection // Uses platform-specific path detection
export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath(); export const ANTIGRAVITY_DB_PATH = getAntigravityDbPath();

View File

@@ -1,9 +1,13 @@
import fs from 'fs'; import fs from 'fs';
import path from 'path'; import path from 'path';
import { USAGE_HISTORY_PATH } from '../constants.js';
// Persistence path // Persistence path
const DATA_DIR = path.join(process.cwd(), 'data'); const HISTORY_FILE = USAGE_HISTORY_PATH;
const HISTORY_FILE = path.join(DATA_DIR, 'usage-history.json'); const DATA_DIR = path.dirname(HISTORY_FILE);
const OLD_DATA_DIR = path.join(process.cwd(), 'data');
const OLD_HISTORY_FILE = path.join(OLD_DATA_DIR, 'usage-history.json');
// In-memory storage // In-memory storage
// Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } } // Structure: { "YYYY-MM-DDTHH:00:00.000Z": { "claude": { "model-name": count, "_subtotal": count }, "_total": count } }
@@ -35,10 +39,22 @@ function getShortName(modelId, family) {
} }
/** /**
* Ensure data directory exists and load history * Ensure data directory exists and load history.
* Includes migration from legacy local data directory.
*/ */
function load() { function load() {
try { try {
// Migration logic: if old file exists and new one doesn't
if (fs.existsSync(OLD_HISTORY_FILE) && !fs.existsSync(HISTORY_FILE)) {
console.log('[UsageStats] Migrating legacy usage data...');
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true });
}
fs.copyFileSync(OLD_HISTORY_FILE, HISTORY_FILE);
// We keep the old file for safety initially, but could delete it
console.log(`[UsageStats] Migration complete: ${OLD_HISTORY_FILE} -> ${HISTORY_FILE}`);
}
if (!fs.existsSync(DATA_DIR)) { if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true }); fs.mkdirSync(DATA_DIR, { recursive: true });
} }

View File

@@ -567,6 +567,19 @@ app.post('/v1/messages', async (req, res) => {
// Ensure account manager is initialized // Ensure account manager is initialized
await ensureInitialized(); await ensureInitialized();
const {
model,
messages,
stream,
system,
max_tokens,
tools,
tool_choice,
thinking,
top_p,
top_k,
temperature
} = req.body;
// Resolve model mapping if configured // Resolve model mapping if configured
let requestedModel = model || 'claude-3-5-sonnet-20241022'; let requestedModel = model || 'claude-3-5-sonnet-20241022';