feat: Add Web UI for account and quota management

## Summary
Add an optional Web UI for managing accounts and monitoring quotas.
WebUI is implemented as a modular plugin with minimal changes to server.js (only 5 lines added).

## New Features
- Dashboard: Real-time model quota visualization with Chart.js
- Accounts: OAuth-based account management (add/enable/disable/refresh/remove)
- Logs: Live server log streaming via SSE with search and level filtering
- Settings: System configuration with 4 tabs
  - Interface: Language (EN/zh_CN), polling interval, log buffer size, display options
  - Claude CLI: Proxy connection config, model selection, alias overrides (~/.claude.json)
  - Models: Model visibility and ordering management
  - Server Info: Runtime info and account config reload

## Technical Changes
- Add src/webui/index.js as modular plugin (all WebUI routes encapsulated)
- Add src/config.js for centralized configuration (~/.config/antigravity-proxy/config.json)
- Add authMiddleware for optional password protection (WEBUI_PASSWORD env var)
- Enhance logger with EventEmitter for SSE log streaming
- Make constants configurable via config.json
- Merge with main v1.2.6 (model fallback, cross-model thinking)
- server.js changes: only 5 lines added to import and mount WebUI module

## Bug Fixes
- Fix Alpine.js $watch error in settings-store.js (not supported in store init)
- Fix "OK" label to "SUCCESS" in logs filter
- Add saveSettings() calls to settings toggles for proper persistence
- Improve Claude CLI config robustness (handle empty/invalid JSON files)
- Add safety check for empty config.env in claude-config component
- Improve config.example.json instructions with clear Windows/macOS/Linux paths

## New Files
- src/webui/index.js - WebUI module with all API routes
- public/ - Complete Web UI frontend (Alpine.js + TailwindCSS + DaisyUI)
- src/config.js - Configuration management
- src/utils/claude-config.js - Claude CLI settings helper
- tests/frontend/ - Frontend test suite

## API Endpoints Added
- GET/POST /api/config - Server configuration
- GET/POST /api/claude/config - Claude CLI configuration
- POST /api/models/config - Model alias/hidden settings
- GET /api/accounts - Account list with status
- POST /api/accounts/:email/toggle - Enable/disable account
- POST /api/accounts/:email/refresh - Refresh account token
- DELETE /api/accounts/:email - Remove account
- GET /api/logs - Log history
- GET /api/logs/stream - Live log streaming (SSE)
- GET /api/auth/url - OAuth URL generation
- GET /oauth/callback - OAuth callback handler

## Backward Compatibility
- Default port remains 8080
- All existing CLI/API functionality unchanged
- WebUI is entirely optional
- Can be disabled by removing mountWebUI() call
This commit is contained in:
Wha1eChai
2026-01-04 08:32:36 +08:00
parent d03c79cc39
commit 85f7d3bae7
34 changed files with 4330 additions and 23 deletions

111
src/utils/claude-config.js Normal file
View File

@@ -0,0 +1,111 @@
/**
* Claude CLI Configuration Utility
*
* Handles reading and writing to the global Claude CLI settings file.
* Location: ~/.claude/settings.json (Windows: %USERPROFILE%\.claude\settings.json)
*/
import fs from 'fs/promises';
import path from 'path';
import os from 'os';
import { logger } from './logger.js';
/**
* Get the path to the global Claude CLI settings file
* @returns {string} Absolute path to settings.json
*/
export function getClaudeConfigPath() {
return path.join(os.homedir(), '.claude', 'settings.json');
}
/**
* Read the global Claude CLI configuration
* @returns {Promise<Object>} The configuration object or empty object if file missing
*/
export async function readClaudeConfig() {
const configPath = getClaudeConfigPath();
try {
const content = await fs.readFile(configPath, 'utf8');
if (!content.trim()) return { env: {} };
return JSON.parse(content);
} catch (error) {
if (error.code === 'ENOENT') {
logger.warn(`[ClaudeConfig] Config file not found at ${configPath}, returning empty default`);
return { env: {} };
}
if (error instanceof SyntaxError) {
logger.error(`[ClaudeConfig] Invalid JSON in config at ${configPath}. Returning safe default.`);
return { env: {} };
}
logger.error(`[ClaudeConfig] Failed to read config at ${configPath}:`, error.message);
throw error;
}
}
/**
* Update the global Claude CLI configuration
* Performs a deep merge with existing configuration to avoid losing other settings.
*
* @param {Object} updates - The partial configuration to merge in
* @returns {Promise<Object>} The updated full configuration
*/
export async function updateClaudeConfig(updates) {
const configPath = getClaudeConfigPath();
let currentConfig = {};
// 1. Read existing config
try {
currentConfig = await readClaudeConfig();
} catch (error) {
// Ignore ENOENT, otherwise rethrow
if (error.code !== 'ENOENT') throw error;
}
// 2. Deep merge updates
const newConfig = deepMerge(currentConfig, updates);
// 3. Ensure .claude directory exists
const configDir = path.dirname(configPath);
try {
await fs.mkdir(configDir, { recursive: true });
} catch (error) {
// Ignore if exists
}
// 4. Write back to file
try {
await fs.writeFile(configPath, JSON.stringify(newConfig, null, 2), 'utf8');
logger.info(`[ClaudeConfig] Updated config at ${configPath}`);
return newConfig;
} catch (error) {
logger.error(`[ClaudeConfig] Failed to write config:`, error.message);
throw error;
}
}
/**
* Simple deep merge for objects
*/
function deepMerge(target, source) {
const output = { ...target };
if (isObject(target) && isObject(source)) {
Object.keys(source).forEach(key => {
if (isObject(source[key])) {
if (!(key in target)) {
Object.assign(output, { [key]: source[key] });
} else {
output[key] = deepMerge(target[key], source[key]);
}
} else {
Object.assign(output, { [key]: source[key] });
}
});
}
return output;
}
function isObject(item) {
return (item && typeof item === 'object' && !Array.isArray(item));
}