merge: hermes/refactor-design into main - token tracking fixes + usage dashboard redesign
No files matched your search
@@ -12,6 +12,8 @@
|
|||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"make:win": "node scripts/build-windows.js",
|
"make:win": "node scripts/build-windows.js",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
|
"setup": "node scripts/setup-dev.js",
|
||||||
|
"postinstall": "node scripts/setup-dev.js --check-only",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --check .",
|
"format": "prettier --check .",
|
||||||
"format:write": "prettier --write .",
|
"format:write": "prettier --write .",
|
||||||
@@ -102,8 +104,10 @@
|
|||||||
"@nestjs/microservices": "^11.1.18",
|
"@nestjs/microservices": "^11.1.18",
|
||||||
"@nestjs/platform-express": "^11.1.18",
|
"@nestjs/platform-express": "^11.1.18",
|
||||||
"@nestjs/platform-fastify": "^11.0.20",
|
"@nestjs/platform-fastify": "^11.0.20",
|
||||||
|
"@nestjs/websockets": "^11.1.19",
|
||||||
"@nivo/bar": "^0.99.0",
|
"@nivo/bar": "^0.99.0",
|
||||||
"@nivo/core": "^0.99.0",
|
"@nivo/core": "^0.99.0",
|
||||||
|
"@nivo/line": "^0.99.0",
|
||||||
"@nivo/pie": "^0.99.0",
|
"@nivo/pie": "^0.99.0",
|
||||||
"@orpc/client": "^1.13.14",
|
"@orpc/client": "^1.13.14",
|
||||||
"@orpc/server": "^1.13.14",
|
"@orpc/server": "^1.13.14",
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
let file = fs.readFileSync('src/routes/proxy.tsx', 'utf8');
|
||||||
|
|
||||||
|
// The Proxy layout also uses heavy cards
|
||||||
|
file = file.replace(/<Card>/g, '<Card className="bg-background border border-white/5 shadow-none">');
|
||||||
|
|
||||||
|
fs.writeFileSync('src/routes/proxy.tsx', file);
|
||||||
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
let file = fs.readFileSync('src/routes/settings.tsx', 'utf8');
|
||||||
|
|
||||||
|
// The settings page uses standard cards that look bulky in minimal dark mode
|
||||||
|
file = file.replace(/<div className="flex flex-col gap-6">/g, '<div className="flex flex-col gap-2">');
|
||||||
|
file = file.replace(/<Card>/g, '<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">');
|
||||||
|
file = file.replace(/<CardHeader>/g, '<CardHeader className="px-0 pt-4">');
|
||||||
|
file = file.replace(/<CardContent>/g, '<CardContent className="px-0">');
|
||||||
|
|
||||||
|
fs.writeFileSync('src/routes/settings.tsx', file);
|
||||||
|
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
/**
|
||||||
|
* Cross-platform development environment setup for Gemini Nexus.
|
||||||
|
*
|
||||||
|
* Detects and installs native build toolchains required by better-sqlite3,
|
||||||
|
* keytar, and other native Node.js modules used in this Electron project.
|
||||||
|
*
|
||||||
|
* Platforms:
|
||||||
|
* - Windows → Visual Studio 2022 Build Tools (C++ workload)
|
||||||
|
* - macOS → Xcode Command Line Tools
|
||||||
|
* - Ubuntu → build-essential, python3, libsecret-1-dev
|
||||||
|
* - Manjaro/Arch → base-devel, python, libsecret
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node scripts/setup-dev.js # auto-detect and install/guide
|
||||||
|
* node scripts/setup-dev.js --check-only # only verify, don't install
|
||||||
|
*/
|
||||||
|
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execSync, spawnSync } = require('child_process');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ─── Terminal Color Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const RESET = '\x1b[0m';
|
||||||
|
const BOLD = '\x1b[1m';
|
||||||
|
const DIM = '\x1b[2m';
|
||||||
|
const RED = '\x1b[31m';
|
||||||
|
const GREEN = '\x1b[32m';
|
||||||
|
const YELLOW = '\x1b[33m';
|
||||||
|
const CYAN = '\x1b[36m';
|
||||||
|
const MAGENTA = '\x1b[35m';
|
||||||
|
|
||||||
|
// Disable colors if NO_COLOR is set or output is piped
|
||||||
|
const useColor = process.env.NO_COLOR === undefined && process.stdout.isTTY;
|
||||||
|
|
||||||
|
function color(code, text) {
|
||||||
|
return useColor ? `${code}${text}${RESET}` : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function header(text) {
|
||||||
|
console.log(`\n${color(BOLD + MAGENTA, '══')} ${color(BOLD, text)} ${color(BOLD + MAGENTA, '══')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function success(text) {
|
||||||
|
console.log(` ${color(GREEN, '✓')} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function warn(text) {
|
||||||
|
console.log(` ${color(YELLOW, '⚠')} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function error(text) {
|
||||||
|
console.log(` ${color(RED, '✗')} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(text) {
|
||||||
|
console.log(` ${color(CYAN, '→')} ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dim(text) {
|
||||||
|
console.log(` ${color(DIM, text)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title) {
|
||||||
|
console.log(`\n ${color(BOLD, title)}:`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Platform Detection ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const platform = os.platform(); // 'win32' | 'darwin' | 'linux'
|
||||||
|
const arch = os.arch();
|
||||||
|
|
||||||
|
/** Detect Linux distribution from os-release */
|
||||||
|
function detectLinuxDistro() {
|
||||||
|
try {
|
||||||
|
const osRelease = fs.readFileSync('/etc/os-release', 'utf8');
|
||||||
|
const idMatch = osRelease.match(/^ID="?([^"\n]+)"?/m);
|
||||||
|
const idLikeMatch = osRelease.match(/^ID_LIKE="?([^"\n]+)"?/m);
|
||||||
|
|
||||||
|
const id = idMatch ? idMatch[1].toLowerCase() : '';
|
||||||
|
const idLike = idLikeMatch ? idLikeMatch[1].toLowerCase() : '';
|
||||||
|
|
||||||
|
if (id === 'ubuntu' || id === 'debian' || idLike.includes('debian')) {
|
||||||
|
return 'debian';
|
||||||
|
}
|
||||||
|
if (id === 'arch' || id === 'manjaro' || idLike.includes('arch')) {
|
||||||
|
return 'arch';
|
||||||
|
}
|
||||||
|
if (id === 'fedora' || idLike.includes('fedora') || idLike.includes('rhel')) {
|
||||||
|
return 'fedora';
|
||||||
|
}
|
||||||
|
return id || 'unknown';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tool Checkers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Check if a command exists on PATH */
|
||||||
|
function commandExists(cmd) {
|
||||||
|
try {
|
||||||
|
const whereCmd = platform === 'win32' ? 'where' : 'which';
|
||||||
|
execSync(`${whereCmd} "${cmd}"`, { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if running with administrator/sudo privileges */
|
||||||
|
function hasAdminPrivileges() {
|
||||||
|
if (platform === 'win32') {
|
||||||
|
try {
|
||||||
|
// On Windows, check via net session (requires admin)
|
||||||
|
execSync('net session', { stdio: 'ignore' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// On Unix, check if UID is 0
|
||||||
|
return process.getuid ? process.getuid() === 0 : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check Windows: Visual Studio Build Tools */
|
||||||
|
function checkWindows() {
|
||||||
|
const results = { ok: true, missing: [] };
|
||||||
|
|
||||||
|
// Check for cl.exe (C++ compiler from VS Build Tools)
|
||||||
|
if (commandExists('cl.exe')) {
|
||||||
|
success('MSVC Compiler (cl.exe) found');
|
||||||
|
} else {
|
||||||
|
// Try to find VS installation via vswhere or registry
|
||||||
|
let vsFound = false;
|
||||||
|
|
||||||
|
// vswhere.exe (shipped with VS 2017+)
|
||||||
|
const vswherePaths = [
|
||||||
|
'C:\\Program Files (x86)\\Microsoft Visual Studio\\Installer\\vswhere.exe',
|
||||||
|
'C:\\Program Files\\Microsoft Visual Studio\\Installer\\vswhere.exe',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const vswhere of vswherePaths) {
|
||||||
|
try {
|
||||||
|
const output = execSync(`"${vswhere}" -latest -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`, {
|
||||||
|
stdio: ['ignore', 'pipe', 'ignore'],
|
||||||
|
encoding: 'utf8',
|
||||||
|
}).trim();
|
||||||
|
|
||||||
|
if (output) {
|
||||||
|
vsFound = true;
|
||||||
|
success(`Visual Studio found at: ${output}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// vswhere not found, continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check registry fallback
|
||||||
|
if (!vsFound) {
|
||||||
|
try {
|
||||||
|
execSync(
|
||||||
|
'reg query "HKLM\\SOFTWARE\\Microsoft\\VisualStudio\\SxS\\VS7" /v 17.0',
|
||||||
|
{ stdio: 'ignore' },
|
||||||
|
);
|
||||||
|
vsFound = true;
|
||||||
|
success('Visual Studio 2022 found (registry)');
|
||||||
|
} catch {
|
||||||
|
// Not found in registry either
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!vsFound) {
|
||||||
|
error('MSVC Build Tools not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('msvc');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check Python (needed by node-gyp)
|
||||||
|
if (commandExists('python') || commandExists('python3')) {
|
||||||
|
success('Python found');
|
||||||
|
} else {
|
||||||
|
error('Python not found (required by node-gyp)');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('python');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check macOS: Xcode Command Line Tools */
|
||||||
|
function checkMacOS() {
|
||||||
|
const results = { ok: true, missing: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xcodePath = execSync('xcode-select -p', { stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8' }).trim();
|
||||||
|
if (xcodePath) {
|
||||||
|
success(`Xcode CLT found at: ${xcodePath}`);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error('Xcode Command Line Tools not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('xcode-clt');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandExists('make') || commandExists('gmake')) {
|
||||||
|
success('make found');
|
||||||
|
} else {
|
||||||
|
error('make not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('make');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check Linux: build-essential, python3, libsecret */
|
||||||
|
function checkLinux() {
|
||||||
|
const results = { ok: true, missing: [] };
|
||||||
|
const distro = detectLinuxDistro();
|
||||||
|
|
||||||
|
section(`Linux Distribution Detected: ${distro || 'unknown'}`);
|
||||||
|
|
||||||
|
// gcc / g++
|
||||||
|
if (commandExists('gcc') || commandExists('cc')) {
|
||||||
|
success('C compiler found');
|
||||||
|
} else {
|
||||||
|
error('C compiler not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('compiler');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commandExists('g++') || commandExists('c++')) {
|
||||||
|
success('C++ compiler found');
|
||||||
|
} else {
|
||||||
|
error('C++ compiler not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('compiler');
|
||||||
|
}
|
||||||
|
|
||||||
|
// make
|
||||||
|
if (commandExists('make')) {
|
||||||
|
success('make found');
|
||||||
|
} else {
|
||||||
|
error('make not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('make');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Python 3
|
||||||
|
if (commandExists('python3')) {
|
||||||
|
success('Python 3 found');
|
||||||
|
} else if (commandExists('python')) {
|
||||||
|
// Check if python is python3
|
||||||
|
try {
|
||||||
|
const ver = execSync('python --version', { encoding: 'utf8' });
|
||||||
|
if (ver.includes('3.')) {
|
||||||
|
success('Python 3 found (as python)');
|
||||||
|
} else {
|
||||||
|
error('Python 3 not found (python is v2)');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('python3');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
error('Python not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('python3');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
error('Python 3 not found');
|
||||||
|
results.ok = false;
|
||||||
|
results.missing.push('python3');
|
||||||
|
}
|
||||||
|
|
||||||
|
// libsecret (needed by keytar)
|
||||||
|
try {
|
||||||
|
if (distro === 'debian') {
|
||||||
|
execSync('dpkg -s libsecret-1-dev', { stdio: 'ignore' });
|
||||||
|
success('libsecret-1-dev found');
|
||||||
|
} else if (distro === 'arch') {
|
||||||
|
execSync('pacman -Qi libsecret', { stdio: 'ignore' });
|
||||||
|
success('libsecret found');
|
||||||
|
} else {
|
||||||
|
// Check for pkg-config
|
||||||
|
try {
|
||||||
|
execSync('pkg-config --exists libsecret-1', { stdio: 'ignore' });
|
||||||
|
success('libsecret (pkg-config) found');
|
||||||
|
} catch {
|
||||||
|
warn('libsecret not detected (may not be needed if using prebuilt keytar)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warn('libsecret not detected (may not be needed if using prebuilt keytar)');
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Installation Helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Run a command and stream output, return exit code */
|
||||||
|
function runCommand(cmd, args, opts = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
console.log(`\n ${color(CYAN, 'Running:')} ${color(DIM, `${cmd} ${args.join(' ')}`)}`);
|
||||||
|
|
||||||
|
const child = spawnSync(cmd, args, {
|
||||||
|
stdio: 'inherit',
|
||||||
|
shell: platform === 'win32',
|
||||||
|
...opts,
|
||||||
|
});
|
||||||
|
|
||||||
|
resolve(child.status);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install Windows: Visual Studio 2022 Build Tools via winget */
|
||||||
|
async function installWindows() {
|
||||||
|
// Check if winget is available
|
||||||
|
if (!commandExists('winget')) {
|
||||||
|
console.log('');
|
||||||
|
console.log(color(RED, ' winget not found. Please install Visual Studio Build Tools manually:'));
|
||||||
|
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
||||||
|
console.log('');
|
||||||
|
console.log(' During installation, select:');
|
||||||
|
console.log(color(BOLD, ' "Desktop development with C++" workload'));
|
||||||
|
console.log('');
|
||||||
|
console.log(' Or install via PowerShell (as Administrator):');
|
||||||
|
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--wait --quiet --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
info('Attempting automatic installation of Visual Studio 2022 Build Tools...');
|
||||||
|
dim('This may take several minutes and requires administrator privileges.');
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
if (!hasAdminPrivileges()) {
|
||||||
|
warn('Not running as Administrator. winget may prompt for elevation.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await runCommand('winget', [
|
||||||
|
'install',
|
||||||
|
'Microsoft.VisualStudio.2022.BuildTools',
|
||||||
|
'--override',
|
||||||
|
'"--quiet --wait --norestart --add Microsoft.VisualStudio.Component.VC.Tools.x86.x64 --add Microsoft.VisualStudio.Component.Windows11SDK.22621 --includeRecommended"',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (status === 0) {
|
||||||
|
success('Visual Studio 2022 Build Tools installed successfully.');
|
||||||
|
console.log('');
|
||||||
|
warn('You may need to restart your terminal for PATH changes to take effect.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error('Automatic installation failed. Please install manually.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install macOS: Xcode Command Line Tools */
|
||||||
|
async function installMacOS() {
|
||||||
|
console.log('');
|
||||||
|
info('Installing Xcode Command Line Tools...');
|
||||||
|
dim('A GUI dialog will appear. Click "Install" to proceed.');
|
||||||
|
|
||||||
|
const status = await runCommand('xcode-select', ['--install']);
|
||||||
|
|
||||||
|
// xcode-select --install returns 1 if already installed or cancelled
|
||||||
|
if (status === 0) {
|
||||||
|
success('Xcode Command Line Tools installation initiated.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already installed (common false-negative)
|
||||||
|
try {
|
||||||
|
execSync('xcode-select -p', { stdio: 'ignore' });
|
||||||
|
success('Xcode Command Line Tools are already installed.');
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
warn('Xcode CLT installation was cancelled or failed.');
|
||||||
|
warn('You can install manually via: xcode-select --install');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Install Linux: distribution-specific packages */
|
||||||
|
async function installLinux() {
|
||||||
|
const distro = detectLinuxDistro();
|
||||||
|
|
||||||
|
if (distro === 'debian') {
|
||||||
|
// Ubuntu / Debian
|
||||||
|
if (!hasAdminPrivileges()) {
|
||||||
|
warn('Root privileges required. You may be prompted for your password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await runCommand('sudo', [
|
||||||
|
'apt-get', 'install', '-y',
|
||||||
|
'build-essential',
|
||||||
|
'python3',
|
||||||
|
'libsecret-1-dev',
|
||||||
|
'pkg-config',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (status === 0) {
|
||||||
|
success('Build dependencies installed successfully.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error('Failed to install build dependencies.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distro === 'arch') {
|
||||||
|
// Manjaro / Arch
|
||||||
|
if (!hasAdminPrivileges()) {
|
||||||
|
warn('Root privileges required. You may be prompted for your password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await runCommand('sudo', [
|
||||||
|
'pacman', '-S', '--needed', '--noconfirm',
|
||||||
|
'base-devel',
|
||||||
|
'python',
|
||||||
|
'libsecret',
|
||||||
|
'pkg-config',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (status === 0) {
|
||||||
|
success('Build dependencies installed successfully.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error('Failed to install build dependencies.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (distro === 'fedora') {
|
||||||
|
if (!hasAdminPrivileges()) {
|
||||||
|
warn('Root privileges required. You may be prompted for your password.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await runCommand('sudo', [
|
||||||
|
'dnf', 'install', '-y',
|
||||||
|
'gcc-c++',
|
||||||
|
'make',
|
||||||
|
'python3',
|
||||||
|
'libsecret-devel',
|
||||||
|
'pkg-config',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (status === 0) {
|
||||||
|
success('Build dependencies installed successfully.');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
error('Failed to install build dependencies.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown distro - provide generic instructions
|
||||||
|
console.log('');
|
||||||
|
warn(`Unknown Linux distribution: ${distro}`);
|
||||||
|
console.log('');
|
||||||
|
console.log(' You need the following packages to compile native Node.js modules:');
|
||||||
|
console.log(' - C/C++ compiler (gcc, g++)');
|
||||||
|
console.log(' - GNU Make');
|
||||||
|
console.log(' - Python 3');
|
||||||
|
console.log(' - libsecret development headers (for keytar)');
|
||||||
|
console.log(' - pkg-config');
|
||||||
|
console.log('');
|
||||||
|
console.log(' Install them using your distribution package manager, then run npm install again.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const checkOnly = args.includes('--check-only') || args.includes('--check');
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
console.log(color(BOLD + CYAN, '┌──────────────────────────────────────────┐'));
|
||||||
|
console.log(color(BOLD + CYAN, '│ Gemini Nexus - Dev Environment Setup │'));
|
||||||
|
console.log(color(BOLD + CYAN, '└──────────────────────────────────────────┘'));
|
||||||
|
console.log('');
|
||||||
|
dim(`Platform: ${platform} (${arch})`);
|
||||||
|
dim(`Node.js: ${process.version}`);
|
||||||
|
|
||||||
|
// Step 1: Check current state
|
||||||
|
header('Checking Build Toolchain');
|
||||||
|
|
||||||
|
let results;
|
||||||
|
switch (platform) {
|
||||||
|
case 'win32':
|
||||||
|
results = checkWindows();
|
||||||
|
break;
|
||||||
|
case 'darwin':
|
||||||
|
results = checkMacOS();
|
||||||
|
break;
|
||||||
|
case 'linux':
|
||||||
|
results = checkLinux();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.log('');
|
||||||
|
error(`Unsupported platform: ${platform}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print summary
|
||||||
|
console.log('');
|
||||||
|
if (results.ok) {
|
||||||
|
success('All build tools are present and ready.');
|
||||||
|
console.log('');
|
||||||
|
dim('You can now run: npm install && npm start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Missing tools ──
|
||||||
|
console.log('');
|
||||||
|
error('Some build tools are missing. Native modules like better-sqlite3 and keytar require compilation.');
|
||||||
|
|
||||||
|
if (checkOnly) {
|
||||||
|
console.log('');
|
||||||
|
warn('Build tools are missing. Native modules (better-sqlite3, keytar) require compilation.');
|
||||||
|
warn('Run "npm run setup" to install them automatically, or install manually:');
|
||||||
|
printManualInstructions(platform, results.missing);
|
||||||
|
console.log('');
|
||||||
|
dim('npm install succeeded, but npm start / electron-rebuild will fail until build tools are installed.');
|
||||||
|
// Don't exit with error - allow npm install to succeed (prebuilt binaries may work)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Attempt installation
|
||||||
|
header('Installing Build Dependencies');
|
||||||
|
|
||||||
|
let installed = false;
|
||||||
|
switch (platform) {
|
||||||
|
case 'win32':
|
||||||
|
installed = await installWindows();
|
||||||
|
break;
|
||||||
|
case 'darwin':
|
||||||
|
installed = await installMacOS();
|
||||||
|
break;
|
||||||
|
case 'linux':
|
||||||
|
installed = await installLinux();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Final status
|
||||||
|
console.log('');
|
||||||
|
if (installed) {
|
||||||
|
success('Setup complete. You can now run: npm install && npm start');
|
||||||
|
console.log('');
|
||||||
|
dim('If you encounter issues, restart your terminal and try again.');
|
||||||
|
} else {
|
||||||
|
console.log('');
|
||||||
|
warn('Automatic installation was not successful.');
|
||||||
|
warn('Please install the required tools manually, then run npm install again.');
|
||||||
|
printManualInstructions(platform, results.missing);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function printManualInstructions(platform, missing) {
|
||||||
|
console.log('');
|
||||||
|
console.log(color(BOLD, ' ── Manual Installation Instructions ──'));
|
||||||
|
console.log('');
|
||||||
|
|
||||||
|
switch (platform) {
|
||||||
|
case 'win32':
|
||||||
|
console.log(' 1. Download Visual Studio 2022 Build Tools:');
|
||||||
|
console.log(color(CYAN, ' https://visualstudio.microsoft.com/downloads/#build-tools-for-visual-studio-2022'));
|
||||||
|
console.log('');
|
||||||
|
console.log(' 2. Run the installer and select:');
|
||||||
|
console.log(color(BOLD, ' "Desktop development with C++"'));
|
||||||
|
console.log('');
|
||||||
|
console.log(' 3. Or install via PowerShell (as Administrator):');
|
||||||
|
console.log(color(DIM, ' winget install Microsoft.VisualStudio.2022.BuildTools --override "--quiet --wait --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended"'));
|
||||||
|
console.log('');
|
||||||
|
console.log(' 4. Restart your terminal after installation.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'darwin':
|
||||||
|
console.log(' Run this command in your terminal:');
|
||||||
|
console.log(color(DIM, ' xcode-select --install'));
|
||||||
|
console.log('');
|
||||||
|
console.log(' Then click "Install" in the dialog that appears.');
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'linux': {
|
||||||
|
const distro = detectLinuxDistro();
|
||||||
|
if (distro === 'debian') {
|
||||||
|
console.log(' Run:');
|
||||||
|
console.log(color(DIM, ' sudo apt-get install -y build-essential python3 libsecret-1-dev pkg-config'));
|
||||||
|
} else if (distro === 'arch') {
|
||||||
|
console.log(' Run:');
|
||||||
|
console.log(color(DIM, ' sudo pacman -S --needed base-devel python libsecret pkg-config'));
|
||||||
|
} else if (distro === 'fedora') {
|
||||||
|
console.log(' Run:');
|
||||||
|
console.log(color(DIM, ' sudo dnf install -y gcc-c++ make python3 libsecret-devel pkg-config'));
|
||||||
|
} else {
|
||||||
|
console.log(' Install: C/C++ compiler, make, python3, libsecret-dev, pkg-config');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Run ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error(color(RED, `\nUnexpected error: ${err.message}`));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -30,7 +30,7 @@ const queryClient = new QueryClient();
|
|||||||
const root = createRoot(document.getElementById('app')!);
|
const root = createRoot(document.getElementById('app')!);
|
||||||
root.render(
|
root.render(
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="system">
|
<ThemeProvider storageKey={LOCAL_STORAGE_KEYS.THEME} defaultTheme="dark">
|
||||||
<App />
|
<App />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
|
|||||||
|
After Width: | Height: | Size: 875 KiB |
|
After Width: | Height: | Size: 87 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 736 KiB |
|
Before Width: | Height: | Size: 390 KiB After Width: | Height: | Size: 736 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
Before Width: | Height: | Size: 659 B After Width: | Height: | Size: 44 KiB |
@@ -114,8 +114,8 @@ export const AccountCard: React.FC<AccountCardProps> = ({
|
|||||||
<Card
|
<Card
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative overflow-hidden transition-all duration-200',
|
'relative overflow-hidden transition-all duration-200',
|
||||||
'hover:shadow-primary/10 hover:shadow-xl',
|
|
||||||
isCurrent ? 'border-primary/50 bg-primary/5' : 'hover:border-primary/20 cursor-pointer',
|
isCurrent ? 'border-primary/50 bg-primary/10' : 'hover:bg-accent/10 cursor-pointer border-white/5',
|
||||||
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
(isSwitching || isDeleting) && 'pointer-events-none opacity-60',
|
||||||
)}
|
)}
|
||||||
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
onClick={() => !isCurrent && !isSwitching && !isDeleting && onSwitch(account.id)}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ function AccountProxyItem({ account }: { account: CloudAccount }) {
|
|||||||
/>
|
/>
|
||||||
<div className="w-16">
|
<div className="w-16">
|
||||||
{proxySaved && (
|
{proxySaved && (
|
||||||
<span className="text-[10px] whitespace-nowrap text-green-500">
|
<span className="text-[10px] whitespace-nowrap text-emerald-400">
|
||||||
{t('cloud.card.proxySaved')}
|
{t('cloud.card.proxySaved')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -58,9 +58,9 @@ const MODEL_DISPLAY_REPLACEMENTS: Array<[string, string]> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
const QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||||
high: 'text-green-500',
|
high: 'text-emerald-400',
|
||||||
medium: 'text-yellow-500',
|
medium: 'text-amber-400',
|
||||||
low: 'text-red-500',
|
low: 'text-rose-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
const QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||||
@@ -240,12 +240,12 @@ export function CloudAccountCard({
|
|||||||
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
|
<span className="text-muted-foreground/70 text-[10px] font-bold tracking-wider uppercase">
|
||||||
{title}
|
{title}
|
||||||
</span>
|
</span>
|
||||||
<div className="bg-border/50 h-[1px] flex-1" />
|
<div className="h-[1px] flex-1 bg-white/[0.04]" />
|
||||||
</div>
|
</div>
|
||||||
{models.map(([modelName, info]) => (
|
{models.map(([modelName, info]) => (
|
||||||
<div
|
<div
|
||||||
key={modelName}
|
key={modelName}
|
||||||
className="group/item hover:bg-muted/60 hover:border-border/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-sm transition-all"
|
className="group/item grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 rounded-lg border border-transparent px-2 py-1.5 text-[13px] transition-all hover:bg-white/[0.03]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
className="text-muted-foreground group-hover/item:text-foreground min-w-0 truncate font-semibold"
|
||||||
@@ -291,7 +291,7 @@ export function CloudAccountCard({
|
|||||||
const providerGroupedQuotaSection =
|
const providerGroupedQuotaSection =
|
||||||
providerStats && providerStats.visibleModels > 0 ? (
|
providerStats && providerStats.visibleModels > 0 ? (
|
||||||
<>
|
<>
|
||||||
<div className="bg-muted/40 flex items-center justify-between rounded-lg px-3 py-1.5 text-xs">
|
<div className="flex items-center justify-between rounded-lg bg-white/[0.03] px-3 py-1.5 text-xs">
|
||||||
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
<span className="font-medium">{t('settings.providerGroupings.overall')}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span
|
<span
|
||||||
@@ -343,7 +343,7 @@ export function CloudAccountCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className={`group bg-card hover:border-primary/40 flex h-full flex-col overflow-hidden border transition-all duration-200 hover:shadow-sm ${isSelected ? 'ring-primary border-primary/50 ring-2' : ''}`}
|
className={`group flex h-full flex-col overflow-hidden rounded-xl border border-white/[0.06] bg-card transition-all duration-200 hover:border-white/[0.12] ${isSelected ? 'ring-accent/30 border-accent/40 ring-2' : ''}`}
|
||||||
>
|
>
|
||||||
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
<CardHeader className="relative flex flex-row items-center gap-4 space-y-0 pb-2">
|
||||||
{onToggleSelection && (
|
{onToggleSelection && (
|
||||||
@@ -365,7 +365,7 @@ export function CloudAccountCard({
|
|||||||
className="bg-muted h-10 w-10 rounded-full border"
|
className="bg-muted h-10 w-10 rounded-full border"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-primary/10 text-primary flex h-10 w-10 items-center justify-center rounded-full border">
|
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-white/[0.06] text-muted-foreground">
|
||||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -493,7 +493,7 @@ export function CloudAccountCard({
|
|||||||
{account.provider.toUpperCase()}
|
{account.provider.toUpperCase()}
|
||||||
</Badge>
|
</Badge>
|
||||||
{account.is_active && (
|
{account.is_active && (
|
||||||
<Badge variant="default" className="bg-green-500 text-xs hover:bg-green-600">
|
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-xs hover:bg-emerald-500/20">
|
||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
@@ -514,7 +514,7 @@ export function CloudAccountCard({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{account.is_active ? (
|
{account.is_active ? (
|
||||||
<Button variant="ghost" size="sm" disabled className="text-green-600 opacity-100">
|
<Button variant="ghost" size="sm" disabled className="text-emerald-400 opacity-100">
|
||||||
<Power className="mr-1 h-3 w-3" />
|
<Power className="mr-1 h-3 w-3" />
|
||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -551,7 +551,7 @@ export function CloudAccountCard({
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="bg-muted/20 text-muted-foreground justify-center border-t p-2 px-4 text-xs">
|
<CardFooter className="justify-center border-t border-white/[0.04] p-2 px-4 text-xs text-muted-foreground">
|
||||||
<span>
|
<span>
|
||||||
{t('cloud.card.used')}{' '}
|
{t('cloud.card.used')}{' '}
|
||||||
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
{formatDistanceToNow(account.last_used * 1000, { addSuffix: true })}
|
||||||
@@ -613,7 +613,7 @@ export function CompactCloudAccountCard({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="group bg-card hover:border-primary/40 flex items-center gap-3 rounded-lg border px-3 py-2 transition-all duration-200">
|
<div className="group flex items-center gap-3 rounded-xl border border-white/[0.06] bg-card px-3 py-2 transition-all duration-200 hover:border-white/[0.12]">
|
||||||
{account.avatar_url ? (
|
{account.avatar_url ? (
|
||||||
<img
|
<img
|
||||||
src={account.avatar_url}
|
src={account.avatar_url}
|
||||||
@@ -621,14 +621,14 @@ export function CompactCloudAccountCard({
|
|||||||
className="bg-muted h-7 w-7 rounded-full border"
|
className="bg-muted h-7 w-7 rounded-full border"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="bg-primary/10 text-primary flex h-7 w-7 items-center justify-center rounded-full border text-xs font-semibold">
|
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-white/[0.06] text-xs font-semibold text-muted-foreground">
|
||||||
{account.name?.[0]?.toUpperCase() || 'A'}
|
{account.name?.[0]?.toUpperCase() || 'A'}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-semibold">
|
<span className="truncate text-[13px] font-semibold">
|
||||||
{account.name || t('cloud.card.unknown')}
|
{account.name || t('cloud.card.unknown')}
|
||||||
</span>
|
</span>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -644,7 +644,7 @@ export function CompactCloudAccountCard({
|
|||||||
{account.is_active && (
|
{account.is_active && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="default"
|
variant="default"
|
||||||
className="shrink-0 bg-green-500 text-[10px] hover:bg-green-600"
|
className="shrink-0 bg-emerald-500/15 text-emerald-400 border-emerald-500/20 text-[10px] hover:bg-emerald-500/20"
|
||||||
>
|
>
|
||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -756,7 +756,7 @@ export function CompactCloudAccountCard({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
disabled
|
disabled
|
||||||
className="h-7 text-xs text-green-600 opacity-100"
|
className="h-7 text-xs text-emerald-400 opacity-100"
|
||||||
>
|
>
|
||||||
<Power className="mr-1 h-3 w-3" />
|
<Power className="mr-1 h-3 w-3" />
|
||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
|
|||||||
@@ -85,10 +85,10 @@ import { shouldAutoSubmitGoogleAuthCode } from '@/utils/googleAuthSubmission';
|
|||||||
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
export type GridLayout = 'auto' | '2-col' | '3-col' | 'list' | 'compact';
|
||||||
|
|
||||||
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
const GRID_LAYOUT_CLASSES: Record<GridLayout, string> = {
|
||||||
auto: 'grid gap-4 md:grid-cols-2 xl:grid-cols-3',
|
auto: 'grid gap-3 md:grid-cols-2 xl:grid-cols-3',
|
||||||
'2-col': 'grid gap-4 grid-cols-2',
|
'2-col': 'grid gap-3 grid-cols-2',
|
||||||
'3-col': 'grid gap-4 grid-cols-3',
|
'3-col': 'grid gap-3 grid-cols-3',
|
||||||
list: 'grid gap-4 grid-cols-1',
|
list: 'grid gap-3 grid-cols-1',
|
||||||
compact: 'flex flex-col gap-2',
|
compact: 'flex flex-col gap-2',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,9 +99,9 @@ const GLOBAL_QUOTA_BAR_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
const GLOBAL_QUOTA_TEXT_COLOR_CLASS_BY_STATUS: Record<QuotaStatus, string> = {
|
||||||
high: 'text-emerald-600 dark:text-emerald-400',
|
high: 'text-emerald-400',
|
||||||
medium: 'text-amber-600 dark:text-amber-400',
|
medium: 'text-amber-400',
|
||||||
low: 'text-rose-600 dark:text-rose-400',
|
low: 'text-rose-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function CloudAccountList() {
|
export function CloudAccountList() {
|
||||||
@@ -259,9 +259,7 @@ export function CloudAccountList() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const activeClientKey = oauthClients.find((client) => client.is_active)?.key;
|
const activeClientKey = oauthClients.find((client) => client.is_active)?.key;
|
||||||
if (activeClientKey) {
|
if (activeClientKey && activeClientKey !== selectedOAuthClientKey) { setTimeout(() => setSelectedOAuthClientKey(activeClientKey), 0); }
|
||||||
setSelectedOAuthClientKey(activeClientKey);
|
|
||||||
}
|
|
||||||
}, [oauthClients, selectedOAuthClientKey]);
|
}, [oauthClients, selectedOAuthClientKey]);
|
||||||
// Listen for Google Auth Code
|
// Listen for Google Auth Code
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -665,34 +663,34 @@ export function CloudAccountList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5 pb-20">
|
<div className="space-y-5 pb-20">
|
||||||
<div className="bg-card rounded-lg border p-5">
|
<div className="rounded-xl border border-white/[0.06] p-5">
|
||||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
<div className="flex shrink-0 flex-col gap-1">
|
<div className="flex shrink-0 flex-col gap-1">
|
||||||
<h2 className="text-2xl font-bold tracking-tight">{t('cloud.title')}</h2>
|
<h2 className="text-lg font-semibold tracking-tight">{t('cloud.title')}</h2>
|
||||||
<p className="text-muted-foreground max-w-2xl">{t('cloud.description')}</p>
|
<p className="text-muted-foreground max-w-2xl text-[13px]">{t('cloud.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
<div className="text-muted-foreground text-[11px] uppercase">
|
<div className="text-muted-foreground text-[11px] uppercase">
|
||||||
{t('cloud.card.actions')}
|
{t('cloud.card.actions')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold">{totalAccounts}</div>
|
<div className="text-sm font-semibold tabular-nums">{totalAccounts}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
<div className="text-muted-foreground text-[11px] uppercase">
|
<div className="text-muted-foreground text-[11px] uppercase">
|
||||||
{t('cloud.card.active')}
|
{t('cloud.card.active')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-emerald-600">{activeAccounts}</div>
|
<div className="text-sm font-semibold text-emerald-400">{activeAccounts}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
<div className="text-muted-foreground text-[11px] uppercase">
|
<div className="text-muted-foreground text-[11px] uppercase">
|
||||||
{t('cloud.card.rateLimited')}
|
{t('cloud.card.rateLimited')}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-base font-semibold text-rose-600">{rateLimitedAccounts}</div>
|
<div className="text-sm font-semibold text-rose-400">{rateLimitedAccounts}</div>
|
||||||
</div>
|
</div>
|
||||||
{/* Global Quota */}
|
{/* Global Quota */}
|
||||||
{overallQuotaPercentage !== null && (
|
{overallQuotaPercentage !== null && (
|
||||||
<div className="bg-muted/50 rounded-md border px-3 py-2">
|
<div className="rounded-lg border border-white/[0.06] bg-white/[0.02] px-3 py-2">
|
||||||
<div className="text-muted-foreground text-[11px] uppercase">
|
<div className="text-muted-foreground text-[11px] uppercase">
|
||||||
{t('cloud.globalQuota')}
|
{t('cloud.globalQuota')}
|
||||||
</div>
|
</div>
|
||||||
@@ -715,13 +713,13 @@ export function CloudAccountList() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-card flex flex-wrap items-center gap-2 rounded-lg border p-3">
|
<div className="flex flex-wrap items-center gap-2 rounded-xl border border-white/[0.06] bg-card p-2.5">
|
||||||
<div className="bg-muted/50 flex items-center gap-2 rounded-md border px-3 py-2">
|
<div className="flex items-center gap-2 rounded-lg border border-white/[0.06] px-3 py-1.5">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Zap
|
<Zap
|
||||||
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
className={`h-4 w-4 ${autoSwitchEnabled ? 'fill-yellow-500 text-yellow-500' : 'text-muted-foreground'}`}
|
||||||
/>
|
/>
|
||||||
<Label htmlFor="auto-switch" className="cursor-pointer text-sm font-medium">
|
<Label htmlFor="auto-switch" className="cursor-pointer text-[13px] font-medium">
|
||||||
{t('cloud.autoSwitch')}
|
{t('cloud.autoSwitch')}
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
@@ -1110,7 +1108,7 @@ export function CloudAccountList() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sortedAccounts.length === 0 && (
|
{sortedAccounts.length === 0 && (
|
||||||
<div className="text-muted-foreground bg-muted/20 col-span-full rounded-lg border border-dashed py-14 text-center">
|
<div className="text-muted-foreground col-span-full rounded-xl border border-dashed border-white/[0.1] py-14 text-center">
|
||||||
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
<Cloud className="mx-auto mb-3 h-10 w-10 opacity-40" />
|
||||||
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
<div className="text-sm">{t('cloud.list.noAccounts')}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1119,7 +1117,7 @@ export function CloudAccountList() {
|
|||||||
|
|
||||||
{/* Batch Action Bar */}
|
{/* Batch Action Bar */}
|
||||||
{selectedIds.size > 0 && (
|
{selectedIds.size > 0 && (
|
||||||
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border px-6 py-2 shadow-lg">
|
<div className="bg-card animate-in fade-in slide-in-from-bottom-4 fixed bottom-6 left-1/2 z-50 flex -translate-x-1/2 items-center gap-4 rounded-full border border-white/[0.1] px-6 py-2 shadow-2xl shadow-black/30">
|
||||||
<div className="flex items-center gap-2 border-r pr-4">
|
<div className="flex items-center gap-2 border-r pr-4">
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
{t('cloud.batch.selected', { count: selectedIds.size })}
|
{t('cloud.batch.selected', { count: selectedIds.size })}
|
||||||
|
|||||||
@@ -134,8 +134,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
if (!open) {
|
if (!open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refreshProfiles();
|
setTimeout(() => { void refreshProfiles(); }, 0); }, [open, refreshProfiles]);
|
||||||
}, [open, refreshProfiles]);
|
|
||||||
|
|
||||||
const runAction = async (key: string, action: () => Promise<void>) => {
|
const runAction = async (key: string, action: () => Promise<void>) => {
|
||||||
if (actionLockRef.current) {
|
if (actionLockRef.current) {
|
||||||
@@ -344,7 +343,7 @@ export function IdentityProfileDialog({ account, open, onOpenChange }: IdentityP
|
|||||||
|
|
||||||
{!showLoadingPlaceholder
|
{!showLoadingPlaceholder
|
||||||
? history.map((version) => (
|
? history.map((version) => (
|
||||||
<div key={version.id} className="bg-muted/20 rounded-xl border p-3">
|
<div key={version.id} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-3">
|
||||||
<div className="mb-2 flex items-center justify-between gap-3">
|
<div className="mb-2 flex items-center justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="truncate text-sm font-medium">{version.label}</div>
|
<div className="truncate text-sm font-medium">{version.label}</div>
|
||||||
|
|||||||
@@ -48,12 +48,8 @@ export function ModelVisibilitySettings() {
|
|||||||
|
|
||||||
// Initialize model visibility and provider groupings from config
|
// Initialize model visibility and provider groupings from config
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config?.model_visibility) {
|
if (config?.model_visibility) { setTimeout(() => setModelVisibilityMap(config.model_visibility), 0); }
|
||||||
setModelVisibilityMap(config.model_visibility);
|
if (config?.provider_groupings_enabled !== undefined) { setTimeout(() => setProviderGroupingEnabled(config.provider_groupings_enabled), 0); }
|
||||||
}
|
|
||||||
if (config?.provider_groupings_enabled !== undefined) {
|
|
||||||
setProviderGroupingEnabled(config.provider_groupings_enabled);
|
|
||||||
}
|
|
||||||
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
}, [config?.model_visibility, config?.provider_groupings_enabled]);
|
||||||
|
|
||||||
// Get all unique models from all accounts
|
// Get all unique models from all accounts
|
||||||
@@ -188,7 +184,7 @@ export function ModelVisibilitySettings() {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={modelId}
|
key={modelId}
|
||||||
className="hover:bg-muted/50 flex items-center space-x-3 rounded p-2"
|
className="flex items-center space-x-3 rounded-lg p-2 hover:bg-white/[0.03] transition-colors"
|
||||||
>
|
>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id={`model-${modelId}`}
|
id={`model-${modelId}`}
|
||||||
@@ -210,7 +206,7 @@ export function ModelVisibilitySettings() {
|
|||||||
title={t('proxy.copy')}
|
title={t('proxy.copy')}
|
||||||
>
|
>
|
||||||
{copiedModel === modelId ? (
|
{copiedModel === modelId ? (
|
||||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
<CheckCircle className="h-4 w-4 text-emerald-400" />
|
||||||
) : (
|
) : (
|
||||||
<Copy className="h-4 w-4" />
|
<Copy className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -37,12 +37,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border/60 overflow-hidden rounded-lg border">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
{/* Provider Header */}
|
{/* Provider Header */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleCollapse}
|
onClick={onToggleCollapse}
|
||||||
className="hover:bg-muted/60 flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-sm transition-colors"
|
className="flex w-full cursor-pointer items-center gap-2 px-3 py-2 text-[13px] transition-colors hover:bg-white/[0.03]"
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
<ChevronRight className="text-muted-foreground h-3.5 w-3.5 shrink-0" />
|
||||||
@@ -87,7 +87,7 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted h-1.5 w-16 overflow-hidden rounded-full">
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-white/[0.06]">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
|
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(avgPercentage)}`}
|
||||||
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
|
style={{ width: `${clampQuotaPercentage(avgPercentage)}%` }}
|
||||||
@@ -98,12 +98,12 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
|
|
||||||
{/* Individual model rows (shown when expanded) */}
|
{/* Individual model rows (shown when expanded) */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="border-border/40 border-t">
|
<div className="border-t border-white/[0.06]">
|
||||||
{visibleModels.map((model, index) => (
|
{visibleModels.map((model, index) => (
|
||||||
<div
|
<div
|
||||||
key={model.id}
|
key={model.id}
|
||||||
className={`hover:bg-muted/60 grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 pl-9 text-sm transition-colors ${
|
className={`grid grid-cols-[minmax(0,1fr)_auto] items-start gap-3 px-3 py-2 pl-9 text-[13px] transition-colors hover:bg-white/[0.03] ${
|
||||||
index < visibleModels.length - 1 ? 'border-border/20 border-b' : ''
|
index < visibleModels.length - 1 ? 'border-b border-white/[0.04]' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-muted-foreground min-w-0 truncate" title={model.id}>
|
<span className="text-muted-foreground min-w-0 truncate" title={model.id}>
|
||||||
@@ -126,7 +126,7 @@ export const ProviderGroup: React.FC<ProviderGroupProps> = ({
|
|||||||
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
<span className="text-muted-foreground text-[10px]">{leftLabel}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-muted h-1.5 w-24 overflow-hidden rounded-full">
|
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/[0.06]">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
|
className={`h-full rounded-full transition-all duration-300 ${getQuotaBarColorClass(model.percentage)}`}
|
||||||
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
|
style={{ width: `${clampQuotaPercentage(model.percentage)}%` }}
|
||||||
|
|||||||
@@ -52,36 +52,38 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
|||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip delayDuration={0}>
|
<Tooltip delayDuration={0}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<button
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={isLoading || isPending}
|
disabled={isLoading || isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-10 w-10 rounded-lg transition-all duration-300',
|
'flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-200',
|
||||||
|
'disabled:opacity-50 disabled:cursor-not-allowed',
|
||||||
isRunning
|
isRunning
|
||||||
? 'bg-green-100/50 text-green-700 hover:bg-green-100 hover:text-green-800 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
? 'bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20'
|
||||||
: 'bg-red-100/50 text-red-700 hover:bg-red-100 hover:text-red-800 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50',
|
: 'bg-white/[0.04] text-muted-foreground hover:bg-white/[0.08] hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Power className="h-5 w-5" />
|
<Power className="h-4 w-4" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right" className="flex items-center gap-2">
|
<TooltipContent side="right" className="flex items-center gap-2 text-xs">
|
||||||
<div
|
<div
|
||||||
className={cn('h-2 w-2 rounded-full', isRunning ? 'bg-green-500' : 'bg-red-500')}
|
className={cn(
|
||||||
|
'h-1.5 w-1.5 rounded-full',
|
||||||
|
isRunning ? 'bg-emerald-400' : 'bg-muted-foreground',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
<p>
|
<span>
|
||||||
{isLoading
|
{isLoading
|
||||||
? t('status.checking')
|
? t('status.checking')
|
||||||
: isRunning
|
: isRunning
|
||||||
? t('status.running')
|
? t('status.running')
|
||||||
: t('status.stopped')}
|
: t('status.stopped')}
|
||||||
</p>
|
</span>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
@@ -93,23 +95,33 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between overflow-hidden rounded-lg px-3 py-2.5 text-sm font-medium ring-1 transition-colors ring-inset',
|
'flex items-center justify-between rounded-lg px-3 py-2 transition-all duration-200',
|
||||||
isRunning
|
isRunning
|
||||||
? 'bg-green-100 text-green-900 ring-green-200 dark:bg-green-900/20 dark:text-green-100 dark:ring-green-900/50'
|
? 'bg-emerald-500/[0.08]'
|
||||||
: 'bg-red-100 text-red-900 ring-red-200 dark:bg-red-900/20 dark:text-red-100 dark:ring-red-900/50',
|
: 'bg-white/[0.02]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex flex-1 items-start gap-3">
|
<div className="flex items-center gap-2.5">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Loader2 className="mt-0.5 h-3.5 w-3.5 shrink-0 animate-spin" />
|
<Loader2 className="h-3 w-3 shrink-0 animate-spin text-muted-foreground" />
|
||||||
) : isRunning ? (
|
|
||||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.5)]" />
|
|
||||||
) : (
|
) : (
|
||||||
<div className="mt-1.5 h-2.5 w-2.5 shrink-0 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)]" />
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-2 w-2 shrink-0 rounded-full transition-colors duration-300',
|
||||||
|
isRunning
|
||||||
|
? 'bg-emerald-400 shadow-[0_0_6px_rgba(16,185,129,0.4)]'
|
||||||
|
: 'bg-muted-foreground/50',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1 leading-none">
|
<div className="flex flex-col">
|
||||||
<span className="text-xs font-semibold tracking-wider uppercase opacity-80">Status</span>
|
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
|
||||||
<span className="text-sm leading-tight font-medium">
|
Status
|
||||||
|
</span>
|
||||||
|
<span className={cn(
|
||||||
|
'text-xs font-medium leading-tight',
|
||||||
|
isRunning ? 'text-emerald-400' : 'text-muted-foreground',
|
||||||
|
)}>
|
||||||
{isLoading
|
{isLoading
|
||||||
? t('status.checking')
|
? t('status.checking')
|
||||||
: isRunning
|
: isRunning
|
||||||
@@ -124,22 +136,20 @@ export const StatusBar: React.FC<StatusBarProps> = ({ isCollapsed = false }) =>
|
|||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
disabled={isLoading || isPending}
|
disabled={isLoading || isPending}
|
||||||
className={cn(
|
className={cn(
|
||||||
'ml-2 h-8 shrink-0 rounded-md border px-3 transition-all',
|
'h-7 gap-1.5 rounded-md px-2.5 text-xs',
|
||||||
isRunning
|
isRunning
|
||||||
? 'border-green-200 bg-green-200/50 text-green-900 hover:bg-green-200 dark:border-green-800 dark:bg-green-800/30 dark:text-green-100 dark:hover:bg-green-800/50'
|
? 'text-emerald-400 hover:bg-emerald-500/10'
|
||||||
: 'border-red-200 bg-red-200/50 text-red-900 hover:bg-red-200 dark:border-red-800 dark:bg-red-800/30 dark:text-red-100 dark:hover:bg-red-800/50',
|
: 'text-muted-foreground hover:bg-white/[0.06]',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isPending ? (
|
{isPending ? (
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
) : isRunning ? (
|
) : isRunning ? (
|
||||||
<Square className="h-3.5 w-3.5 fill-current" />
|
<Square className="h-3 w-3 fill-current" />
|
||||||
) : (
|
) : (
|
||||||
<Play className="h-3.5 w-3.5 fill-current" />
|
<Play className="h-3 w-3 fill-current" />
|
||||||
)}
|
)}
|
||||||
<span className="ml-2 font-semibold">
|
<span>{isRunning ? t('action.stop') : t('action.start')}</span>
|
||||||
{isRunning ? t('action.stop') : t('action.start')}
|
|
||||||
</span>
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export default function DragWindowRegion({ title }: DragWindowRegionProps) {
|
|||||||
<div className="flex w-screen items-stretch justify-between">
|
<div className="flex w-screen items-stretch justify-between">
|
||||||
<div className="draglayer w-full">
|
<div className="draglayer w-full">
|
||||||
{title && !isMacOS && (
|
{title && !isMacOS && (
|
||||||
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-gray-400 select-none">
|
<div className="flex flex-1 p-2 text-xs whitespace-nowrap text-muted-foreground select-none">
|
||||||
{title}
|
{title}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { groupBy } from 'lodash-es';
|
import { groupBy } from 'lodash-es';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Loader2, Eye, Zap, Code, Brain, Star } from 'lucide-react';
|
import { Loader2, Eye, Zap, Code, Brain, Star, Layers } from 'lucide-react';
|
||||||
|
|
||||||
interface ModelCapability {
|
interface ModelCapability {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -45,7 +44,8 @@ function getProvider(modelId: string): string {
|
|||||||
const id = modelId.toLowerCase();
|
const id = modelId.toLowerCase();
|
||||||
if (id.startsWith('gemini')) return 'Google';
|
if (id.startsWith('gemini')) return 'Google';
|
||||||
if (id.startsWith('claude')) return 'Anthropic';
|
if (id.startsWith('claude')) return 'Anthropic';
|
||||||
if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.startsWith('text-')) return 'OpenAI';
|
if (id.startsWith('gpt') || id.startsWith('o1') || id.startsWith('o3') || id.startsWith('text-'))
|
||||||
|
return 'OpenAI';
|
||||||
if (id.startsWith('deepseek')) return 'DeepSeek';
|
if (id.startsWith('deepseek')) return 'DeepSeek';
|
||||||
if (id.startsWith('meta-llama') || id.startsWith('llama')) return 'Meta';
|
if (id.startsWith('meta-llama') || id.startsWith('llama')) return 'Meta';
|
||||||
if (id.startsWith('mistral') || id.startsWith('mixtral')) return 'Mistral';
|
if (id.startsWith('mistral') || id.startsWith('mixtral')) return 'Mistral';
|
||||||
@@ -56,6 +56,17 @@ function getProvider(modelId: string): string {
|
|||||||
return 'Other';
|
return 'Other';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format large token counts with K/M suffixes */
|
||||||
|
function formatTokenCount(value: number): string {
|
||||||
|
if (value >= 1_000_000) {
|
||||||
|
return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (value >= 1_000) {
|
||||||
|
return `${(value / 1_000).toFixed(0)}K`;
|
||||||
|
}
|
||||||
|
return value.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
export const CapabilitiesTab: React.FC = () => {
|
export const CapabilitiesTab: React.FC = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -73,7 +84,6 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
return groupBy(models, (m) => getProvider(m.id));
|
return groupBy(models, (m) => getProvider(m.id));
|
||||||
}, [models]);
|
}, [models]);
|
||||||
|
|
||||||
// Sort providers alphabetically, but keep 'Other' at the bottom
|
|
||||||
const sortedProviders = Object.keys(groupedModels).sort((a, b) => {
|
const sortedProviders = Object.keys(groupedModels).sort((a, b) => {
|
||||||
if (a === 'Other') return 1;
|
if (a === 'Other') return 1;
|
||||||
if (b === 'Other') return -1;
|
if (b === 'Other') return -1;
|
||||||
@@ -81,41 +91,80 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-6">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="flex items-center justify-between">
|
||||||
<h3 className="text-lg font-medium">{t('proxy.advanced.capabilities.title', 'Model Capabilities')}</h3>
|
<div>
|
||||||
<p className="text-sm text-muted-foreground">{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}</p>
|
<h3 className="text-base font-semibold">
|
||||||
|
{t('proxy.advanced.capabilities.title', 'Model Capabilities')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
{t('proxy.advanced.capabilities.description', 'Discover what each model can do')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{models.length > 0 && (
|
||||||
|
<Badge variant="outline" className="text-xs tabular-nums">
|
||||||
|
<Layers className="mr-1.5 h-3 w-3" />
|
||||||
|
{models.length} models
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Capabilities Grid */}
|
{/* Content */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-40 items-center justify-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{sortedProviders.map((provider) => (
|
{sortedProviders.map((provider) => (
|
||||||
<div key={provider} className="space-y-4">
|
<div key={provider} className="space-y-3">
|
||||||
<h4 className="text-md font-semibold text-foreground border-b pb-2">
|
{/* Provider header */}
|
||||||
{provider}
|
<div className="flex items-center gap-3">
|
||||||
</h4>
|
<h4 className="text-[13px] font-semibold tracking-wide uppercase text-muted-foreground">
|
||||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
|
{provider}
|
||||||
{groupedModels[provider].map((model) => (
|
</h4>
|
||||||
<Card key={model.id} className="flex flex-col transition-shadow hover:shadow-md">
|
<div className="h-px flex-1 bg-white/[0.06]" />
|
||||||
<CardHeader className="pb-3">
|
<span className="text-[11px] text-muted-foreground/60 tabular-nums">
|
||||||
<div className="flex items-center justify-between">
|
{groupedModels[provider].length}
|
||||||
<CardTitle className="text-base truncate pr-2" title={model.displayName || model.id}>
|
</span>
|
||||||
{model.displayName || model.id}
|
</div>
|
||||||
</CardTitle>
|
|
||||||
<Badge variant="outline" className="shrink-0">{model.object}</Badge>
|
{/* Model cards */}
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{groupedModels[provider].map((model) => {
|
||||||
|
const enabledCount = Object.values(model.capabilities).filter(Boolean).length;
|
||||||
|
const totalCount = Object.values(model.capabilities).length;
|
||||||
|
const hasLimits = Object.values(model.limits).some((v) => v != null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="group flex flex-col rounded-xl border border-white/[0.06] bg-card p-4 transition-all hover:border-white/[0.12] hover:bg-white/[0.02]"
|
||||||
|
>
|
||||||
|
{/* Model header */}
|
||||||
|
<div className="mb-3 space-y-1">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<h5
|
||||||
|
className="text-[13px] font-semibold leading-tight truncate"
|
||||||
|
title={model.displayName || model.id}
|
||||||
|
>
|
||||||
|
{model.displayName || model.id}
|
||||||
|
</h5>
|
||||||
|
<span className="shrink-0 text-[10px] font-medium text-muted-foreground/60 rounded-md bg-white/[0.04] px-1.5 py-0.5">
|
||||||
|
{enabledCount}/{totalCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="font-mono text-[11px] text-muted-foreground truncate"
|
||||||
|
title={model.id}
|
||||||
|
>
|
||||||
|
{model.id}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<CardDescription className="text-xs font-mono text-muted-foreground truncate" title={model.id}>
|
|
||||||
{model.id}
|
{/* Capability pills */}
|
||||||
</CardDescription>
|
<div className="flex flex-wrap gap-1.5 mb-3">
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4 flex-1 flex flex-col justify-between">
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{Object.entries(model.capabilities).map(([key, supported]) => {
|
{Object.entries(model.capabilities).map(([key, supported]) => {
|
||||||
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
const iconKey = key as keyof typeof CAPABILITY_ICONS;
|
||||||
const Icon = CAPABILITY_ICONS[iconKey];
|
const Icon = CAPABILITY_ICONS[iconKey];
|
||||||
@@ -123,63 +172,57 @@ export const CapabilitiesTab: React.FC = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className={`flex items-center gap-2 rounded-md border p-2 ${
|
className={`inline-flex items-center gap-1 rounded-md px-2 py-1 text-[10px] font-medium transition-colors ${
|
||||||
supported
|
supported
|
||||||
? 'border-green-200 bg-green-50 dark:border-green-900 dark:bg-green-950/30'
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/15'
|
||||||
: 'border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-900/50'
|
: 'bg-white/[0.02] text-muted-foreground/50 border border-white/[0.04]'
|
||||||
}`}
|
}`}
|
||||||
|
title={label}
|
||||||
>
|
>
|
||||||
<Icon
|
<Icon className="h-3 w-3 shrink-0" />
|
||||||
className={`h-4 w-4 shrink-0 ${
|
{label}
|
||||||
supported ? 'text-green-600 dark:text-green-400' : 'text-gray-400'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<span
|
|
||||||
className={`text-[11px] font-medium truncate ${
|
|
||||||
supported
|
|
||||||
? 'text-green-700 dark:text-green-300'
|
|
||||||
: 'text-gray-500 dark:text-gray-400'
|
|
||||||
}`}
|
|
||||||
title={label}
|
|
||||||
>
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
{Object.values(model.limits).some((v) => v != null) && (
|
|
||||||
<div className="space-y-2 pt-2 border-t mt-auto">
|
{/* Limits section */}
|
||||||
<p className="text-xs font-medium text-muted-foreground">Limits</p>
|
{hasLimits && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="mt-auto flex flex-wrap gap-1.5 border-t border-white/[0.04] pt-3">
|
||||||
{model.limits.maxTokens != null && (
|
{model.limits.maxTokens != null && (
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5">
|
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||||
Context: {model.limits.maxTokens.toLocaleString()}
|
CTX {formatTokenCount(model.limits.maxTokens)}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
{model.limits.maxOutputTokens != null && (
|
{model.limits.maxOutputTokens != null && (
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5">
|
<span className="inline-flex items-center rounded-md bg-white/[0.04] px-1.5 py-0.5 font-mono text-[10px] text-muted-foreground">
|
||||||
Output: {model.limits.maxOutputTokens.toLocaleString()}
|
OUT {formatTokenCount(model.limits.maxOutputTokens)}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
{model.limits.thinkingBudget != null && (
|
{model.limits.thinkingBudget != null && (
|
||||||
<Badge variant="secondary" className="text-[10px] font-normal px-1.5 border-blue-200 bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:border-blue-800 dark:text-blue-300">
|
<span className="inline-flex items-center rounded-md bg-blue-500/10 border border-blue-500/15 px-1.5 py-0.5 font-mono text-[10px] text-blue-300">
|
||||||
Think: {model.limits.thinkingBudget.toLocaleString()}
|
THINK {formatTokenCount(model.limits.thinkingBudget)}
|
||||||
</Badge>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{models.length === 0 && (
|
{models.length === 0 && (
|
||||||
<div className="text-center py-10 text-muted-foreground border rounded-lg border-dashed">
|
<div className="flex flex-col items-center justify-center gap-3 rounded-xl border border-dashed border-white/[0.1] py-16 text-center">
|
||||||
No models found. Check your provider configurations.
|
<Layers className="h-8 w-8 text-muted-foreground/30" />
|
||||||
</div>
|
<div className="space-y-1">
|
||||||
|
<p className="text-[13px] font-medium">No models found</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Check your provider configurations
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import React, { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -113,26 +112,25 @@ export const MonitorTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Circuit Breakers */}
|
{/* Circuit Breakers */}
|
||||||
<Card>
|
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||||
<CardHeader>
|
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||||
<CardTitle className="text-sm font-semibold">
|
<h4 className="text-[13px] font-semibold">
|
||||||
{t('proxy.advanced.circuitBreaker.title')}
|
{t('proxy.advanced.circuitBreaker.title')}
|
||||||
</CardTitle>
|
</h4>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="p-5">
|
||||||
{cbLoading ? (
|
{cbLoading ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex h-20 items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : !circuitBreakers ? (
|
) : !circuitBreakers ? (
|
||||||
<p className="text-sm text-muted-foreground">No data</p>
|
<p className="text-[13px] text-muted-foreground">No data</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
{Object.entries(circuitBreakers.states).map(([api, state]) => (
|
||||||
<Card key={api} className="bg-muted/30">
|
<div key={api} className="rounded-xl border border-white/[0.06] bg-white/[0.02] p-4">
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">{api}</span>
|
<span className="text-[13px] font-medium">{api}</span>
|
||||||
<Badge variant={getCircuitBadgeVariant(state.state)}>
|
<Badge variant={getCircuitBadgeVariant(state.state)}>
|
||||||
{state.state}
|
{state.state}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -146,19 +144,18 @@ export const MonitorTab: React.FC = () => {
|
|||||||
{new Date(state.lastFailure).toLocaleString()}
|
{new Date(state.lastFailure).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{/* Traffic Logs */}
|
{/* Traffic Logs */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h3 className="text-sm font-semibold">
|
<h3 className="text-[13px] font-semibold">
|
||||||
{t('proxy.advanced.trafficLog.title')}
|
{t('proxy.advanced.trafficLog.title')}
|
||||||
</h3>
|
</h3>
|
||||||
{stats && (
|
{stats && (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
@@ -76,7 +76,7 @@ export const ReplayTab: React.FC = () => {
|
|||||||
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
if (!status) return <Badge variant="outline">{t('proxy.advanced.replay.pending')}</Badge>;
|
||||||
if (status >= 200 && status < 300) {
|
if (status >= 200 && status < 300) {
|
||||||
return (
|
return (
|
||||||
<Badge variant="default" className="bg-green-600 hover:bg-green-700">
|
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/20 hover:bg-emerald-500/20">
|
||||||
<CheckCircle className="mr-1 h-3 w-3" />
|
<CheckCircle className="mr-1 h-3 w-3" />
|
||||||
{status}
|
{status}
|
||||||
</Badge>
|
</Badge>
|
||||||
@@ -98,8 +98,8 @@ export const ReplayTab: React.FC = () => {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">{t('proxy.advanced.replay.title')}</h3>
|
<h3 className="text-base font-semibold">{t('proxy.advanced.replay.title')}</h3>
|
||||||
<p className="text-sm text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
|
<p className="text-[13px] text-muted-foreground">{t('proxy.advanced.replay.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -107,26 +107,26 @@ export const ReplayTab: React.FC = () => {
|
|||||||
onClick={() => refetchRequests()}
|
onClick={() => refetchRequests()}
|
||||||
disabled={requestsLoading}
|
disabled={requestsLoading}
|
||||||
>
|
>
|
||||||
<RefreshCw className={`mr-2 h-4 w-4 ${requestsLoading ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`mr-1.5 h-3.5 w-3.5 ${requestsLoading ? 'animate-spin' : ''}`} />
|
||||||
{t('proxy.advanced.replay.refresh')}
|
{t('proxy.advanced.replay.refresh')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Requests List */}
|
{/* Requests List */}
|
||||||
<Card>
|
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||||
<CardHeader>
|
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
||||||
<CardTitle>{t('proxy.advanced.replay.recentRequests')}</CardTitle>
|
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.replay.recentRequests')}</h4>
|
||||||
<CardDescription>{t('proxy.advanced.replay.clickToReplay')}</CardDescription>
|
<p className="text-xs text-muted-foreground">{t('proxy.advanced.replay.clickToReplay')}</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="p-5">
|
||||||
{requestsLoading ? (
|
{requestsLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<Loader2 className="h-6 w-6 animate-spin" />
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : requests.length === 0 ? (
|
) : requests.length === 0 ? (
|
||||||
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-lg border border-dashed">
|
<div className="flex h-32 flex-col items-center justify-center gap-2 rounded-xl border border-dashed border-white/[0.1]">
|
||||||
<Clock className="h-8 w-8 text-muted-foreground" />
|
<Clock className="h-6 w-6 text-muted-foreground/30" />
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-[13px] text-muted-foreground">
|
||||||
{t('proxy.advanced.replay.noRequests')}
|
{t('proxy.advanced.replay.noRequests')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,12 +135,12 @@ export const ReplayTab: React.FC = () => {
|
|||||||
{requests.map((request) => (
|
{requests.map((request) => (
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
key={request.id}
|
||||||
className="flex items-center justify-between rounded-lg border p-3 transition-colors hover:bg-muted/50"
|
className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3 transition-colors hover:bg-white/[0.02]"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{getStatusBadge(request.status)}
|
{getStatusBadge(request.status)}
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<span className="text-sm font-medium">
|
<span className="text-[13px] font-medium">
|
||||||
{request.method} {request.endpoint}
|
{request.method} {request.endpoint}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
@@ -166,8 +166,8 @@ export const ReplayTab: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -2,7 +2,7 @@ import React from 'react';
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useQuery, useQueries } from '@tanstack/react-query';
|
import { useQuery, useQueries } from '@tanstack/react-query';
|
||||||
import { ipc } from '@/ipc/manager';
|
import { ipc } from '@/ipc/manager';
|
||||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useToast } from '@/components/ui/use-toast';
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
import { StatCard } from '@/components/usage/StatCard';
|
import { StatCard } from '@/components/usage/StatCard';
|
||||||
@@ -87,12 +87,12 @@ export const ToolsTab: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* IDE Quick Setup */}
|
{/* IDE Quick Setup */}
|
||||||
<Card>
|
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||||
<CardHeader>
|
<div className="border-b border-white/[0.06] px-5 py-4 space-y-1">
|
||||||
<CardTitle>{t('proxy.advanced.tools.ideSetup')}</CardTitle>
|
<h4 className="text-[13px] font-semibold">{t('proxy.advanced.tools.ideSetup')}</h4>
|
||||||
<CardDescription>Quick configuration for popular IDEs</CardDescription>
|
<p className="text-xs text-muted-foreground">Quick configuration for popular IDEs</p>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-3">
|
<div className="p-5 space-y-3">
|
||||||
{isIdeLoading ? (
|
{isIdeLoading ? (
|
||||||
<div className="flex h-20 items-center justify-center">
|
<div className="flex h-20 items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
@@ -101,11 +101,11 @@ export const ToolsTab: React.FC = () => {
|
|||||||
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
|
IDE_CONFIGS.map(({ key, label, icon: Icon }) => {
|
||||||
const instructions = getIdeInstructions(key);
|
const instructions = getIdeInstructions(key);
|
||||||
return (
|
return (
|
||||||
<div key={key} className="rounded-lg border">
|
<div key={key} className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="flex items-center justify-between p-3">
|
<div className="flex items-center justify-between p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Icon className="h-4 w-4 text-muted-foreground" />
|
<Icon className="h-4 w-4 text-muted-foreground" />
|
||||||
<span className="text-sm font-medium">{label}</span>
|
<span className="text-[13px] font-medium">{label}</span>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -118,7 +118,7 @@ export const ToolsTab: React.FC = () => {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{instructions && (
|
{instructions && (
|
||||||
<pre className="overflow-x-auto border-t bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||||
{instructions}
|
{instructions}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
@@ -126,8 +126,8 @@ export const ToolsTab: React.FC = () => {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
import { Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
interface TrafficLogEntry {
|
interface TrafficLogEntry {
|
||||||
@@ -74,26 +74,26 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
|||||||
}, [logs, sortKey, sortDir]);
|
}, [logs, sortKey, sortDir]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<div className="overflow-hidden rounded-xl border border-white/[0.06] bg-card">
|
||||||
<CardHeader>
|
<div className="border-b border-white/[0.06] px-5 py-4">
|
||||||
<CardTitle className="text-sm font-semibold">
|
<h4 className="text-[13px] font-semibold">
|
||||||
{t('proxy.advanced.trafficLog.title')}
|
{t('proxy.advanced.trafficLog.title')}
|
||||||
</CardTitle>
|
</h4>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent>
|
<div className="p-5">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-32 items-center justify-center">
|
<div className="flex h-32 items-center justify-center">
|
||||||
<Loader2 className="h-5 w-5 animate-spin" />
|
<Loader2 className="h-5 w-5 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : logs.length === 0 ? (
|
) : logs.length === 0 ? (
|
||||||
<div className="py-8 text-center text-sm text-muted-foreground">
|
<div className="py-8 text-center text-[13px] text-muted-foreground">
|
||||||
{t('proxy.advanced.trafficLog.noLogs')}
|
{t('proxy.advanced.trafficLog.noLogs')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b text-left text-xs text-muted-foreground">
|
<tr className="border-b border-white/[0.06] text-left text-[10px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||||
<th
|
<th
|
||||||
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
className="cursor-pointer pb-2 pr-4 font-medium select-none"
|
||||||
onClick={() => toggleSort('timestamp')}
|
onClick={() => toggleSort('timestamp')}
|
||||||
@@ -142,7 +142,7 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
|||||||
{sortedLogs.map((log, i) => (
|
{sortedLogs.map((log, i) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${log.requestId}-${i}`}
|
key={`${log.requestId}-${i}`}
|
||||||
className="border-b border-border/40 last:border-0"
|
className="border-b border-white/[0.04] last:border-0"
|
||||||
>
|
>
|
||||||
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
<td className="py-2 pr-4 font-mono text-xs text-muted-foreground">
|
||||||
{new Date(log.timestamp).toLocaleTimeString()}
|
{new Date(log.timestamp).toLocaleTimeString()}
|
||||||
@@ -184,7 +184,7 @@ export const TrafficLogTable: React.FC<TrafficLogTableProps> = ({ logs, isLoadin
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { cva, type VariantProps } from 'class-variance-authority';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority';
|
||||||
|
|
||||||
const badgeVariants = cva(
|
const badgeVariants = cva(
|
||||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
'inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring/30',
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
default: 'bg-accent/15 text-accent border border-accent/20',
|
||||||
secondary:
|
secondary: 'bg-secondary text-secondary-foreground border border-white/[0.06]',
|
||||||
'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
destructive: 'bg-destructive/15 text-red-400 border border-destructive/20',
|
||||||
destructive:
|
outline: 'border border-white/[0.1] text-muted-foreground',
|
||||||
'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
|
||||||
outline: 'text-foreground',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
@@ -21,10 +19,11 @@ const badgeVariants = cva(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export interface BadgeProps
|
function Badge({
|
||||||
extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
className,
|
||||||
|
variant,
|
||||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
...props
|
||||||
|
}: React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof badgeVariants>) {
|
||||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,24 +5,27 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const buttonVariants = cva(
|
const buttonVariants = cva(
|
||||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-[13px] font-medium transition-all duration-200 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-1 focus-visible:ring-offset-background",
|
||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: 'bg-primary text-primary-foreground shadow-xs hover:bg-primary/90',
|
default:
|
||||||
|
'bg-primary text-primary-foreground hover:bg-primary/90 active:scale-[0.98]',
|
||||||
destructive:
|
destructive:
|
||||||
'bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60',
|
'bg-destructive/90 text-white hover:bg-destructive active:scale-[0.98] focus-visible:ring-destructive/30',
|
||||||
outline:
|
outline:
|
||||||
'border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50',
|
'border border-white/[0.08] bg-transparent hover:bg-white/[0.04] hover:border-white/[0.12] text-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80',
|
secondary:
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50',
|
'bg-secondary text-secondary-foreground hover:bg-secondary/80 active:scale-[0.98]',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
ghost:
|
||||||
|
'hover:bg-white/[0.06] text-muted-foreground hover:text-foreground',
|
||||||
|
link: 'text-accent underline-offset-4 hover:underline',
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2 has-[>svg]:px-3',
|
default: 'h-9 px-4 py-2',
|
||||||
sm: 'h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5',
|
sm: 'h-8 rounded-md gap-1.5 px-3 text-xs',
|
||||||
lg: 'h-10 rounded-md px-6 has-[>svg]:px-4',
|
lg: 'h-10 rounded-lg px-6',
|
||||||
icon: 'size-9',
|
icon: 'size-8 rounded-lg',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
@@ -5,7 +5,10 @@ const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElemen
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('bg-card text-card-foreground rounded-xl border shadow', className)}
|
className={cn(
|
||||||
|
'bg-card text-card-foreground rounded-xl border border-white/[0.06] shadow-sm transition-colors duration-200',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -23,7 +26,7 @@ const CardTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HT
|
|||||||
({ className, ...props }, ref) => (
|
({ className, ...props }, ref) => (
|
||||||
<h3
|
<h3
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('leading-none font-semibold tracking-tight', className)}
|
className={cn('text-sm font-semibold leading-none tracking-tight', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
@@ -34,7 +37,7 @@ const CardDescription = React.forwardRef<
|
|||||||
HTMLParagraphElement,
|
HTMLParagraphElement,
|
||||||
React.HTMLAttributes<HTMLParagraphElement>
|
React.HTMLAttributes<HTMLParagraphElement>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<p ref={ref} className={cn('text-muted-foreground text-sm', className)} {...props} />
|
<p ref={ref} className={cn('text-muted-foreground text-[13px]', className)} {...props} />
|
||||||
));
|
));
|
||||||
CardDescription.displayName = 'CardDescription';
|
CardDescription.displayName = 'CardDescription';
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ const Checkbox = React.forwardRef<
|
|||||||
<CheckboxPrimitive.Root
|
<CheckboxPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer border-primary ring-offset-background focus-visible:ring-ring data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground h-4 w-4 shrink-0 rounded-sm border focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
'peer h-4 w-4 shrink-0 rounded-[4px] border border-white/[0.15] bg-transparent ring-offset-background transition-colors focus-visible:ring-2 focus-visible:ring-accent/30 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:border-accent data-[state=checked]:bg-accent data-[state=checked]:text-background',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { Cross2Icon } from '@radix-ui/react-icons';
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
@@ -19,7 +19,8 @@ const DialogOverlay = React.forwardRef<
|
|||||||
<DialogPrimitive.Overlay
|
<DialogPrimitive.Overlay
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
|
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -36,14 +37,18 @@ const DialogContent = React.forwardRef<
|
|||||||
<DialogPrimitive.Content
|
<DialogPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
|
'fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||||
|
'gap-4 rounded-xl border border-white/[0.08] bg-card p-6 shadow-2xl shadow-black/30',
|
||||||
|
'duration-200',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
|
<DialogPrimitive.Close className="absolute top-4 right-4 flex h-6 w-6 items-center justify-center rounded-md opacity-50 transition-all hover:bg-white/[0.06] hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring/30 disabled:pointer-events-none">
|
||||||
<Cross2Icon className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</DialogPrimitive.Close>
|
</DialogPrimitive.Close>
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
@@ -70,7 +75,7 @@ const DialogTitle = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Title
|
<DialogPrimitive.Title
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-lg leading-none font-semibold tracking-tight', className)}
|
className={cn('text-base leading-none font-semibold tracking-tight', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -82,7 +87,7 @@ const DialogDescription = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DialogPrimitive.Description
|
<DialogPrimitive.Description
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('text-muted-foreground text-sm', className)}
|
className={cn('text-muted-foreground text-[13px]', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent data-[state=open]:bg-accent flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none',
|
'flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] outline-none select-none transition-colors focus:bg-white/[0.06] data-[state=open]:bg-white/[0.06]',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -39,7 +39,7 @@ const DropdownMenuSubContent = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-md border p-1 shadow-lg',
|
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] p-1 shadow-xl shadow-black/20',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -72,7 +72,7 @@ const DropdownMenuContent = React.forwardRef<
|
|||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
collisionPadding={collisionPadding}
|
collisionPadding={collisionPadding}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-md border p-1 shadow-md',
|
'bg-popover text-popover-foreground z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-hidden overflow-y-auto rounded-lg border border-white/[0.08] p-1 shadow-xl shadow-black/20',
|
||||||
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -92,7 +92,7 @@ const DropdownMenuItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex cursor-default items-center rounded-md px-2 py-1.5 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
inset && 'pl-8',
|
inset && 'pl-8',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
@@ -108,7 +108,7 @@ const DropdownMenuCheckboxItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
checked={checked}
|
checked={checked}
|
||||||
@@ -131,7 +131,7 @@ const DropdownMenuRadioItem = React.forwardRef<
|
|||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm transition-colors outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] transition-colors outline-none select-none focus:bg-white/[0.06] focus:text-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -154,7 +154,7 @@ const DropdownMenuLabel = React.forwardRef<
|
|||||||
>(({ className, inset, ...props }, ref) => (
|
>(({ className, inset, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('px-2 py-1.5 text-sm font-semibold', inset && 'pl-8', className)}
|
className={cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', inset && 'pl-8', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -166,7 +166,7 @@ const DropdownMenuSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
className={cn('-mx-1 my-1 h-px bg-white/[0.06]', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,15 +1,18 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<'input'>>(
|
||||||
|
|
||||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
||||||
({ className, type, ...props }, ref) => {
|
({ className, type, ...props }, ref) => {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
type={type}
|
type={type}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-input placeholder:text-muted-foreground focus-visible:ring-ring flex h-9 w-full rounded-md border bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium focus-visible:ring-1 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
'flex h-9 w-full rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-1 text-[13px] text-foreground shadow-none transition-colors duration-200',
|
||||||
|
'file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus-visible:outline-none focus-visible:border-accent/50 focus-visible:ring-1 focus-visible:ring-accent/20',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { cva, type VariantProps } from 'class-variance-authority';
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
'text-[13px] font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||||
);
|
);
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
|
|||||||
@@ -19,14 +19,18 @@ const SelectTrigger = React.forwardRef<
|
|||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-input bg-background ring-offset-background placeholder:text-muted-foreground focus:ring-ring flex h-10 w-full items-center justify-between rounded-md border px-3 py-2 text-sm focus:ring-2 focus:ring-offset-2 focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
'flex h-9 w-full items-center justify-between rounded-lg border border-white/[0.08] bg-muted/50 px-3 py-2 text-[13px] text-foreground transition-colors duration-200',
|
||||||
|
'placeholder:text-muted-foreground',
|
||||||
|
'focus:outline-none focus:border-accent/50 focus:ring-1 focus:ring-accent/20',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'[&>span]:line-clamp-1',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<SelectPrimitive.Icon asChild>
|
<SelectPrimitive.Icon asChild>
|
||||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
<ChevronDown className="h-3.5 w-3.5 opacity-40" />
|
||||||
</SelectPrimitive.Icon>
|
</SelectPrimitive.Icon>
|
||||||
</SelectPrimitive.Trigger>
|
</SelectPrimitive.Trigger>
|
||||||
));
|
));
|
||||||
@@ -41,7 +45,7 @@ const SelectScrollUpButton = React.forwardRef<
|
|||||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUp className="h-4 w-4" />
|
<ChevronUp className="h-3.5 w-3.5" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
));
|
));
|
||||||
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName;
|
||||||
@@ -55,7 +59,7 @@ const SelectScrollDownButton = React.forwardRef<
|
|||||||
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronDown className="h-4 w-4" />
|
<ChevronDown className="h-3.5 w-3.5" />
|
||||||
</SelectPrimitive.ScrollDownButton>
|
</SelectPrimitive.ScrollDownButton>
|
||||||
));
|
));
|
||||||
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName;
|
||||||
@@ -68,7 +72,9 @@ const SelectContent = React.forwardRef<
|
|||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border shadow-md',
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-lg border border-white/[0.08] bg-popover text-popover-foreground shadow-xl shadow-black/20',
|
||||||
|
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
position === 'popper' &&
|
position === 'popper' &&
|
||||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
className,
|
className,
|
||||||
@@ -98,7 +104,7 @@ const SelectLabel = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('py-1.5 pr-2 pl-8 text-sm font-semibold', className)}
|
className={cn('py-1.5 pr-2 pl-8 text-xs font-medium text-muted-foreground', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
@@ -111,14 +117,16 @@ const SelectItem = React.forwardRef<
|
|||||||
<SelectPrimitive.Item
|
<SelectPrimitive.Item
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'focus:bg-accent focus:text-accent-foreground relative flex w-full cursor-default items-center rounded-sm py-1.5 pr-2 pl-8 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
'relative flex w-full cursor-default items-center rounded-md py-1.5 pr-2 pl-8 text-[13px] outline-none select-none transition-colors',
|
||||||
|
'focus:bg-white/[0.06] focus:text-foreground',
|
||||||
|
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<Check className="h-4 w-4" />
|
<Check className="h-3.5 w-3.5 text-accent" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
@@ -133,7 +141,7 @@ const SelectSeparator = React.forwardRef<
|
|||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn('bg-muted -mx-1 my-1 h-px', className)}
|
className={cn('-mx-1 my-1 h-px bg-white/[0.06]', className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
function Skeleton({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) {
|
||||||
return <div className={cn('bg-muted animate-pulse rounded-md', className)} {...props} />;
|
return (
|
||||||
|
<div
|
||||||
|
className={cn('animate-pulse rounded-lg bg-white/[0.04]', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export { Skeleton };
|
export { Skeleton };
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import * as SwitchPrimitive from '@radix-ui/react-switch';
|
import * as SwitchPrimitives from '@radix-ui/react-switch';
|
||||||
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
const Switch = React.forwardRef<
|
const Switch = React.forwardRef<
|
||||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
React.ElementRef<typeof SwitchPrimitives.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<SwitchPrimitive.Root
|
<SwitchPrimitives.Root
|
||||||
className={cn(
|
className={cn(
|
||||||
'peer focus-visible:ring-ring focus-visible:ring-offset-background data-[state=checked]:bg-primary data-[state=unchecked]:bg-input inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent shadow-none transition-colors duration-200',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||||
|
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
'data-[state=checked]:bg-accent data-[state=unchecked]:bg-white/[0.1]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
ref={ref}
|
ref={ref}
|
||||||
>
|
>
|
||||||
<SwitchPrimitive.Thumb
|
<SwitchPrimitives.Thumb
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background pointer-events-none block h-4 w-4 rounded-full shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
'pointer-events-none block h-4 w-4 rounded-full bg-white shadow-sm ring-0 transition-transform duration-200',
|
||||||
|
'data-[state=checked]:translate-x-[18px] data-[state=unchecked]:translate-x-0.5',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</SwitchPrimitive.Root>
|
</SwitchPrimitives.Root>
|
||||||
));
|
));
|
||||||
Switch.displayName = SwitchPrimitive.Root.displayName;
|
Switch.displayName = SwitchPrimitives.Root.displayName;
|
||||||
|
|
||||||
export { Switch };
|
export { Switch };
|
||||||
@@ -11,7 +11,7 @@ const TabsList = React.forwardRef<
|
|||||||
<TabsPrimitive.List
|
<TabsPrimitive.List
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted text-muted-foreground inline-flex h-10 items-center justify-center rounded-md p-1',
|
'bg-muted/40 text-muted-foreground inline-flex h-9 items-center justify-center rounded-lg p-1 border border-white/[0.04]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -26,7 +26,11 @@ const TabsTrigger = React.forwardRef<
|
|||||||
<TabsPrimitive.Trigger
|
<TabsPrimitive.Trigger
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'ring-offset-background focus-visible:ring-ring data-[state=active]:bg-background data-[state=active]:text-foreground inline-flex items-center justify-center rounded-sm px-3 py-1.5 text-sm font-medium whitespace-nowrap transition-all focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm',
|
'inline-flex items-center justify-center rounded-md px-3 py-1 text-[13px] font-medium whitespace-nowrap transition-all duration-200',
|
||||||
|
'text-muted-foreground hover:text-foreground',
|
||||||
|
'data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm data-[state=active]:border data-[state=active]:border-white/[0.08]',
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||||
|
'disabled:pointer-events-none disabled:opacity-50',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -41,7 +45,7 @@ const TabsContent = React.forwardRef<
|
|||||||
<TabsPrimitive.Content
|
<TabsPrimitive.Content
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={cn(
|
className={cn(
|
||||||
'ring-offset-background focus-visible:ring-ring mt-2 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none',
|
'mt-3 animate-in focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ const TooltipContent = React.forwardRef<
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-popover text-popover-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 origin-[--radix-tooltip-content-transform-origin] overflow-hidden rounded-md border px-3 py-1.5 text-sm shadow-md',
|
'z-50 overflow-hidden rounded-lg border border-white/[0.08] bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lg shadow-black/20',
|
||||||
|
'animate-in fade-in-0 zoom-in-95',
|
||||||
|
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||||
|
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
|
import { AlertTriangle, BarChart3, RotateCcw } from 'lucide-react';
|
||||||
@@ -30,20 +29,22 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card className="border-border/60 bg-card/60 flex flex-col">
|
<div className="flex flex-col rounded-xl border border-white/[0.06] bg-card">
|
||||||
<CardContent className="flex flex-1 flex-col p-4">
|
<div className="flex flex-col p-5">
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-foreground text-sm font-semibold tracking-tight">{title}</h3>
|
<h3 className="text-[13px] font-semibold tracking-tight text-foreground">{title}</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex h-[280px] flex-col gap-3">
|
<div className="flex h-[280px] flex-col gap-3">
|
||||||
<Skeleton className="h-full w-full" />
|
<Skeleton className="h-full w-full rounded-lg" />
|
||||||
</div>
|
</div>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
<div className="flex h-[280px] flex-col items-center justify-center gap-3">
|
||||||
<AlertTriangle className="text-destructive h-5 w-5" />
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-destructive/10">
|
||||||
<p className="text-muted-foreground text-sm">{errorMessage}</p>
|
<AlertTriangle className="h-5 w-5 text-destructive" />
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground text-[13px]">{errorMessage}</p>
|
||||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
<RotateCcw className="mr-1.5 h-3.5 w-3.5" />
|
||||||
{retryLabel}
|
{retryLabel}
|
||||||
@@ -51,8 +52,10 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
) : isEmpty ? (
|
) : isEmpty ? (
|
||||||
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
<div className="flex h-[280px] flex-col items-center justify-center gap-2">
|
||||||
<BarChart3 className="text-muted-foreground/50 h-6 w-6" />
|
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-white/[0.04]">
|
||||||
<p className="text-foreground text-sm font-medium">{emptyTitle}</p>
|
<BarChart3 className="h-5 w-5 text-muted-foreground/60" />
|
||||||
|
</div>
|
||||||
|
<p className="text-[13px] font-medium text-foreground">{emptyTitle}</p>
|
||||||
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
<p className="text-muted-foreground max-w-[240px] text-center text-xs">
|
||||||
{emptyDescription}
|
{emptyDescription}
|
||||||
</p>
|
</p>
|
||||||
@@ -61,7 +64,7 @@ export const ChartCard: React.FC<ChartStatesProps> = ({
|
|||||||
children
|
children
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,17 +1,21 @@
|
|||||||
export const COLOR_PROMPT = 'var(--chart-2)';
|
/** Premium chart palette — refined for dark mode contrast */
|
||||||
export const COLOR_COMPLETION = 'var(--chart-1)';
|
export const COLOR_PROMPT = '#60a5fa'; // blue-400
|
||||||
export const COLOR_TOTAL = 'var(--chart-3)';
|
export const COLOR_COMPLETION = '#34d399'; // emerald-400
|
||||||
|
export const COLOR_TOTAL = '#fbbf24'; // amber-400
|
||||||
|
|
||||||
export const PIE_COLORS = [
|
export const PIE_COLORS = [
|
||||||
'var(--chart-1)',
|
'#60a5fa', '#34d399', '#fbbf24', '#a78bfa', '#f472b6',
|
||||||
'var(--chart-2)',
|
'#38bdf8', '#4ade80', '#facc15', '#c084fc',
|
||||||
'var(--chart-3)',
|
|
||||||
'var(--chart-4)',
|
|
||||||
'var(--chart-5)',
|
|
||||||
'var(--chart-1)',
|
|
||||||
'var(--chart-2)',
|
|
||||||
'var(--chart-3)',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
export const MODEL_COLORS: Record<string, string> = {
|
||||||
|
'gemini-3-flash': '#60a5fa',
|
||||||
|
'gemini-3.1-pro-low': '#818cf8',
|
||||||
|
'gemini-3.1-pro-high': '#a78bfa',
|
||||||
|
'claude-sonnet-4-6-thinking': '#f472b6',
|
||||||
|
'claude-opus-4-6-thinking': '#fb7185',
|
||||||
|
};
|
||||||
|
|
||||||
const colorMap: Record<string, string> = {
|
const colorMap: Record<string, string> = {
|
||||||
Prompt: COLOR_PROMPT,
|
Prompt: COLOR_PROMPT,
|
||||||
Completion: COLOR_COMPLETION,
|
Completion: COLOR_COMPLETION,
|
||||||
@@ -22,7 +26,7 @@ export function BarTooltip({ indexValue, data }: any) {
|
|||||||
const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]);
|
const keys = Object.keys(data).filter((k: string) => k !== 'name' && colorMap[k]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
|
<p className="text-foreground mb-1.5 text-xs font-medium">{indexValue}</p>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
{keys.map((key: string) => (
|
{keys.map((key: string) => (
|
||||||
@@ -44,7 +48,7 @@ export function BarTooltip({ indexValue, data }: any) {
|
|||||||
|
|
||||||
export function PieTooltip({ datum }: any) {
|
export function PieTooltip({ datum }: any) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-popover border-border rounded-md border px-3 py-2 shadow-md">
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<span
|
<span
|
||||||
className="h-2 w-2 rounded-full"
|
className="h-2 w-2 rounded-full"
|
||||||
@@ -58,3 +62,21 @@ export function PieTooltip({ datum }: any) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function LineTooltip({ point }: any) {
|
||||||
|
return (
|
||||||
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
|
<p className="text-foreground mb-1 text-xs font-medium">{point.data.xFormatted ?? point.data.x}</p>
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: point.serieColor }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">{point.serieId}:</span>
|
||||||
|
<span className="text-foreground font-mono font-medium">
|
||||||
|
{typeof point.data.y === 'number' ? point.data.y.toLocaleString() : point.data.y}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ArrowUpRight, ArrowDownRight, Minus } from 'lucide-react';
|
||||||
import type { LucideIcon } from 'lucide-react';
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TrendInfo {
|
||||||
|
/** Percentage change vs previous period */
|
||||||
|
value: number;
|
||||||
|
direction: 'up' | 'down' | 'neutral';
|
||||||
|
}
|
||||||
|
|
||||||
interface StatCardProps {
|
interface StatCardProps {
|
||||||
label: string;
|
label: string;
|
||||||
value: number | string;
|
value: number | string;
|
||||||
@@ -11,24 +17,106 @@ interface StatCardProps {
|
|||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
accent?: 'blue' | 'green' | 'amber' | 'slate' | 'purple';
|
accent?: 'blue' | 'green' | 'amber' | 'slate' | 'purple';
|
||||||
|
trend?: TrendInfo;
|
||||||
|
sparkline?: number[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACCENT_MAP = {
|
const ACCENT_ICON = {
|
||||||
blue: 'text-blue-400',
|
blue: 'text-blue-400',
|
||||||
green: 'text-emerald-400',
|
green: 'text-emerald-400',
|
||||||
amber: 'text-amber-400',
|
amber: 'text-amber-400',
|
||||||
slate: 'text-slate-400',
|
slate: 'text-muted-foreground',
|
||||||
purple: 'text-purple-400',
|
purple: 'text-violet-400',
|
||||||
};
|
};
|
||||||
|
|
||||||
const BG_MAP = {
|
const ACCENT_BG = {
|
||||||
blue: 'bg-blue-500/5',
|
blue: 'bg-blue-500/8',
|
||||||
green: 'bg-emerald-500/5',
|
green: 'bg-emerald-500/8',
|
||||||
amber: 'bg-amber-500/5',
|
amber: 'bg-amber-500/8',
|
||||||
slate: 'bg-slate-500/5',
|
slate: 'bg-white/[0.04]',
|
||||||
purple: 'bg-purple-500/5',
|
purple: 'bg-violet-500/8',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render a tiny inline SVG sparkline from an array of values.
|
||||||
|
* Normalized to fit within a 60x20 viewBox.
|
||||||
|
*/
|
||||||
|
function Sparkline({ data, accent }: { data: number[]; accent: string }) {
|
||||||
|
if (data.length < 2) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const max = Math.max(...data);
|
||||||
|
const min = Math.min(...data);
|
||||||
|
const range = max - min || 1;
|
||||||
|
const w = 60;
|
||||||
|
const h = 20;
|
||||||
|
const pad = 1;
|
||||||
|
|
||||||
|
const points = data.map((v, i) => {
|
||||||
|
const x = (i / (data.length - 1)) * w;
|
||||||
|
const y = h - pad - ((v - min) / range) * (h - pad * 2);
|
||||||
|
return `${x},${y}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const strokeColor =
|
||||||
|
accent === 'blue'
|
||||||
|
? '#60a5fa'
|
||||||
|
: accent === 'green'
|
||||||
|
? '#34d399'
|
||||||
|
: accent === 'amber'
|
||||||
|
? '#fbbf24'
|
||||||
|
: accent === 'purple'
|
||||||
|
? '#a78bfa'
|
||||||
|
: '#6b7280';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox={`0 0 ${w} ${h}`}
|
||||||
|
className="h-5 w-[60px] overflow-visible"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id={`sparkGrad-${accent}`} x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0%" stopColor={strokeColor} stopOpacity="0.3" />
|
||||||
|
<stop offset="100%" stopColor={strokeColor} stopOpacity="0" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
{/* Area fill */}
|
||||||
|
<polygon
|
||||||
|
points={`0,${h} ${points.join(' ')} ${w},${h}`}
|
||||||
|
fill={`url(#sparkGrad-${accent})`}
|
||||||
|
/>
|
||||||
|
{/* Line */}
|
||||||
|
<polyline
|
||||||
|
points={points.join(' ')}
|
||||||
|
fill="none"
|
||||||
|
stroke={strokeColor}
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TrendBadge({ trend }: { trend: TrendInfo }) {
|
||||||
|
const isUp = trend.direction === 'up';
|
||||||
|
const isNeutral = trend.direction === 'neutral' || trend.value === 0;
|
||||||
|
const Icon = isNeutral ? Minus : isUp ? ArrowUpRight : ArrowDownRight;
|
||||||
|
const color = isNeutral
|
||||||
|
? 'text-muted-foreground'
|
||||||
|
: isUp
|
||||||
|
? 'text-emerald-400'
|
||||||
|
: 'text-rose-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('flex items-center gap-0.5 text-[10px] font-medium', color)}>
|
||||||
|
<Icon className="h-3 w-3" />
|
||||||
|
<span className="tabular-nums">{Math.abs(trend.value).toFixed(1)}%</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const StatCard: React.FC<StatCardProps> = ({
|
export const StatCard: React.FC<StatCardProps> = ({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
@@ -36,36 +124,47 @@ export const StatCard: React.FC<StatCardProps> = ({
|
|||||||
isLoading,
|
isLoading,
|
||||||
className,
|
className,
|
||||||
accent = 'slate',
|
accent = 'slate',
|
||||||
|
trend,
|
||||||
|
sparkline,
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<Card
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'border-border/60 bg-card/60 hover:bg-card backdrop-blur-none transition-colors',
|
'group relative rounded-xl border border-white/[0.06] bg-card p-4 transition-all duration-200',
|
||||||
|
'hover:border-white/[0.1] hover:bg-white/[0.02]',
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardContent className="flex items-center justify-between p-4">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1.5">
|
||||||
<span className="text-muted-foreground text-xs font-medium tracking-wide uppercase">
|
<span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
|
||||||
{label}
|
{label}
|
||||||
</span>
|
</span>
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<Skeleton className="h-7 w-24" />
|
<Skeleton className="h-7 w-20" />
|
||||||
) : (
|
) : (
|
||||||
<span className="text-foreground font-mono text-2xl font-semibold tracking-tight">
|
<div className="flex items-baseline gap-2">
|
||||||
{typeof value === 'number' ? value.toLocaleString() : value}
|
<span className="text-foreground font-mono text-xl font-semibold tracking-tight tabular-nums">
|
||||||
</span>
|
{typeof value === 'number' ? value.toLocaleString() : value}
|
||||||
|
</span>
|
||||||
|
{trend && <TrendBadge trend={trend} />}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div className="flex flex-col items-end gap-2">
|
||||||
className={cn(
|
<div
|
||||||
'flex h-9 w-9 shrink-0 items-center justify-center rounded-md',
|
className={cn(
|
||||||
BG_MAP[accent],
|
'flex h-8 w-8 shrink-0 items-center justify-center rounded-lg',
|
||||||
|
ACCENT_BG[accent],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className={cn('h-4 w-4', ACCENT_ICON[accent])} />
|
||||||
|
</div>
|
||||||
|
{sparkline && sparkline.length >= 2 && !isLoading && (
|
||||||
|
<Sparkline data={sparkline} accent={accent} />
|
||||||
)}
|
)}
|
||||||
>
|
|
||||||
<Icon className={cn('h-4 w-4', ACCENT_MAP[accent])} />
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -153,6 +153,10 @@ export function ensureDatabaseInitialized(dbPath: string): void {
|
|||||||
if (!hasRequestType) {
|
if (!hasRequestType) {
|
||||||
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT');
|
db.exec('ALTER TABLE token_usage ADD COLUMN request_type TEXT');
|
||||||
}
|
}
|
||||||
|
const hasIsEstimated = tokenUsageTableInfo.some((col) => col.name === 'is_estimated');
|
||||||
|
if (!hasIsEstimated) {
|
||||||
|
db.exec('ALTER TABLE token_usage ADD COLUMN is_estimated INTEGER');
|
||||||
|
}
|
||||||
|
|
||||||
// Create indexes for token_usage
|
// Create indexes for token_usage
|
||||||
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`);
|
db.exec(`CREATE INDEX IF NOT EXISTS idx_token_usage_account ON token_usage(account_id);`);
|
||||||
|
|||||||
@@ -65,14 +65,21 @@ export const MainLayout: React.FC = () => {
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-muted/10 group relative flex flex-col border-r transition-all duration-300 ease-in-out',
|
'relative flex flex-col border-r border-white/[0.06] transition-all duration-300 ease-[cubic-bezier(0.4,0,0.2,1)]',
|
||||||
isCollapsed ? 'w-[70px]' : 'w-64',
|
isCollapsed ? 'w-[60px]' : 'w-56',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Button
|
{/* Collapse toggle */}
|
||||||
variant="ghost"
|
<button
|
||||||
size="icon"
|
className={cn(
|
||||||
className="bg-background hover:bg-accent hover:text-accent-foreground absolute top-6 -right-3 z-10 h-6 w-6 rounded-full border opacity-0 shadow-md transition-opacity group-hover:opacity-100"
|
'absolute -right-3 top-7 z-10 flex h-6 w-6 items-center justify-center rounded-full',
|
||||||
|
'bg-muted border border-white/[0.1] text-muted-foreground',
|
||||||
|
'opacity-0 transition-all duration-200 hover:bg-white/[0.1] hover:text-foreground',
|
||||||
|
'group-hover:opacity-100',
|
||||||
|
)}
|
||||||
|
style={{ opacity: 0 }}
|
||||||
|
onMouseEnter={(e) => (e.currentTarget.style.opacity = '1')}
|
||||||
|
onMouseLeave={(e) => (e.currentTarget.style.opacity = '0')}
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
@@ -80,11 +87,12 @@ export const MainLayout: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<ChevronLeft className="h-3 w-3" />
|
<ChevronLeft className="h-3 w-3" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
|
|
||||||
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-4' : 'p-6')}>
|
{/* Brand */}
|
||||||
<div className="flex items-center gap-2 overflow-hidden whitespace-nowrap">
|
<div className={cn('flex flex-col', isCollapsed ? 'items-center p-3' : 'px-4 py-5')}>
|
||||||
<div className="flex h-6 w-6 shrink-0 items-center justify-center overflow-hidden rounded bg-transparent">
|
<div className="flex items-center gap-2.5 overflow-hidden whitespace-nowrap">
|
||||||
|
<div className="flex h-7 w-7 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-transparent">
|
||||||
<img
|
<img
|
||||||
src={iconPng}
|
src={iconPng}
|
||||||
alt="Gemini Nexus Logo"
|
alt="Gemini Nexus Logo"
|
||||||
@@ -97,12 +105,13 @@ export const MainLayout: React.FC = () => {
|
|||||||
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
isCollapsed ? 'w-0 opacity-0' : 'w-auto opacity-100',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<h1 className="text-xl font-bold tracking-tight">Gemini Nexus</h1>
|
<h1 className="text-[15px] font-semibold tracking-tight">Gemini Nexus</h1>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-2 px-2">
|
{/* Navigation */}
|
||||||
|
<nav className="flex-1 space-y-1 px-2 pt-2">
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
{navItems.map((item) => {
|
{navItems.map((item) => {
|
||||||
const isActive = location.pathname === item.to;
|
const isActive = location.pathname === item.to;
|
||||||
@@ -114,17 +123,19 @@ export const MainLayout: React.FC = () => {
|
|||||||
<Link
|
<Link
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={cn(
|
className={cn(
|
||||||
'mx-auto flex h-10 w-10 items-center justify-center rounded-md transition-colors',
|
'mx-auto flex h-9 w-9 items-center justify-center rounded-lg transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-white/[0.08] text-foreground'
|
||||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-5 w-5" />
|
<item.icon className="h-[18px] w-[18px]" />
|
||||||
<span className="sr-only">{item.label}</span>
|
<span className="sr-only">{item.label}</span>
|
||||||
</Link>
|
</Link>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
<TooltipContent side="right" className="text-xs">
|
||||||
|
{item.label}
|
||||||
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -134,13 +145,13 @@ export const MainLayout: React.FC = () => {
|
|||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
|
'flex items-center gap-3 rounded-lg px-3 py-2 text-[13px] font-medium transition-all duration-200',
|
||||||
isActive
|
isActive
|
||||||
? 'bg-primary text-primary-foreground'
|
? 'bg-white/[0.08] text-foreground'
|
||||||
: 'hover:bg-muted text-muted-foreground hover:text-foreground',
|
: 'text-muted-foreground hover:bg-white/[0.04] hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="h-4 w-4" />
|
<item.icon className="h-[18px] w-[18px]" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@@ -148,7 +159,8 @@ export const MainLayout: React.FC = () => {
|
|||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="border-t p-2">
|
{/* Status bar */}
|
||||||
|
<div className="border-t border-white/[0.06] p-2">
|
||||||
<StatusBar isCollapsed={isCollapsed} />
|
<StatusBar isCollapsed={isCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -174,9 +186,9 @@ export const MainLayout: React.FC = () => {
|
|||||||
}}
|
}}
|
||||||
fallbackRender={({ resetErrorBoundary }) => (
|
fallbackRender={({ resetErrorBoundary }) => (
|
||||||
<div className="mx-auto max-w-3xl p-6">
|
<div className="mx-auto max-w-3xl p-6">
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
<div className="rounded-xl border border-white/[0.06] border-dashed p-8 text-center">
|
||||||
<div className="text-lg font-semibold">{t('error.generic')}</div>
|
<div className="text-sm font-semibold">{t('error.generic')}</div>
|
||||||
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
|
<div className="text-muted-foreground mt-2 text-xs">{t('action.retry')}</div>
|
||||||
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
<Button className="mt-4" variant="outline" onClick={resetErrorBoundary}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t('action.retry')}
|
{t('action.retry')}
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
import type { PartialTheme } from '@nivo/theming';
|
import type { PartialTheme } from '@nivo/theming';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nivo chart theme aligned with Gemini Nexus design system.
|
||||||
|
* Uses Inter font family and refined dark palette.
|
||||||
|
*/
|
||||||
export const nivoTheme: PartialTheme = {
|
export const nivoTheme: PartialTheme = {
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
|
fontFamily: "Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fill: '#b8b8b8',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
axis: {
|
axis: {
|
||||||
domain: {
|
domain: {
|
||||||
line: {
|
line: {
|
||||||
stroke: 'rgba(255,255,255,0.1)',
|
stroke: 'rgba(255,255,255,0.06)',
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -20,41 +24,41 @@ export const nivoTheme: PartialTheme = {
|
|||||||
strokeWidth: 0,
|
strokeWidth: 0,
|
||||||
},
|
},
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
|
fontFamily: "'JetBrains Mono', monospace",
|
||||||
fontSize: 11,
|
fontSize: 10,
|
||||||
fill: '#b8b8b8',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: {
|
legend: {
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
|
fontFamily: "Inter, sans-serif",
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fill: '#b8b8b8',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
line: {
|
line: {
|
||||||
stroke: 'rgba(255,255,255,0.06)',
|
stroke: 'rgba(255,255,255,0.04)',
|
||||||
strokeWidth: 1,
|
strokeWidth: 1,
|
||||||
strokeDasharray: '4 4',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legends: {
|
legends: {
|
||||||
text: {
|
text: {
|
||||||
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
|
fontFamily: "Inter, sans-serif",
|
||||||
fontSize: 12,
|
fontSize: 11,
|
||||||
fill: '#b8b8b8',
|
fill: '#6b7280',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
container: {
|
container: {
|
||||||
background: '#2e2e2e',
|
background: '#16161e',
|
||||||
color: '#fff',
|
color: '#e8eaed',
|
||||||
fontFamily: "Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif",
|
fontFamily: "Inter, sans-serif",
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
borderRadius: 6,
|
borderRadius: 8,
|
||||||
boxShadow: '0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06)',
|
border: '1px solid rgba(255,255,255,0.06)',
|
||||||
|
boxShadow: '0 8px 24px -4px rgba(0, 0, 0, 0.3)',
|
||||||
padding: '8px 12px',
|
padding: '8px 12px',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ function HomePageErrorBoundary({ error, reset }: { error: unknown; reset: () =>
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl p-6">
|
<div className="container mx-auto max-w-4xl p-6">
|
||||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
<div className="rounded-xl border border-dashed border-white/[0.1] p-8 text-center">
|
||||||
<div className="text-lg font-semibold">{t('error.generic')}</div>
|
<div className="text-base font-semibold">{t('error.generic')}</div>
|
||||||
<div className="text-muted-foreground mt-2 text-sm">{t('action.retry')}</div>
|
<div className="text-muted-foreground mt-2 text-[13px]">{t('action.retry')}</div>
|
||||||
<Button className="mt-4" variant="outline" onClick={() => reset()}>
|
<Button className="mt-4" variant="outline" onClick={() => reset()}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
{t('action.retry')}
|
{t('action.retry')}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ function ProxyPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (localIps && localIps.length > 0 && !selectedIp) {
|
if (localIps && localIps.length > 0 && !selectedIp) {
|
||||||
const recommended = localIps.find((ip) => ip.isRecommended);
|
const recommended = localIps.find((ip) => ip.isRecommended);
|
||||||
setSelectedIp(recommended?.address || localIps[0].address);
|
setTimeout(() => setSelectedIp(recommended?.address || localIps[0].address), 0);
|
||||||
}
|
}
|
||||||
}, [localIps, selectedIp]);
|
}, [localIps, selectedIp]);
|
||||||
|
|
||||||
@@ -208,7 +208,7 @@ function ProxyPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!availableModelIds.includes(activeModelTab) && availableModelIds.length > 0) {
|
if (!availableModelIds.includes(activeModelTab) && availableModelIds.length > 0) {
|
||||||
setActiveModelTab(availableModelIds[0]);
|
setTimeout(() => setActiveModelTab(availableModelIds[0]), 0);
|
||||||
}
|
}
|
||||||
}, [availableModelIds, activeModelTab]);
|
}, [availableModelIds, activeModelTab]);
|
||||||
|
|
||||||
@@ -294,10 +294,10 @@ print(response.choices[0].message.content)`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
<div className="container mx-auto max-w-4xl space-y-5 overflow-auto p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">{t('proxy.title')}</h2>
|
<h2 className="text-lg font-semibold tracking-tight">{t('proxy.title')}</h2>
|
||||||
<p className="text-muted-foreground mt-1">{t('proxy.description')}</p>
|
<p className="text-muted-foreground mt-1 text-[13px]">{t('proxy.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="status">
|
<Tabs defaultValue="status">
|
||||||
@@ -311,7 +311,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* Status Tab */}
|
{/* Status Tab */}
|
||||||
<TabsContent value="status" className="space-y-5">
|
<TabsContent value="status" className="space-y-5">
|
||||||
{/* Service Control Card */}
|
{/* Service Control Card */}
|
||||||
<Card>
|
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
@@ -320,15 +320,15 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div
|
<div
|
||||||
className={`h-3 w-3 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-green-500' : 'bg-gray-400'}`}
|
className={`h-2.5 w-2.5 rounded-full ${proxyConfig.enabled ? 'animate-pulse bg-emerald-400' : 'bg-muted-foreground/30'}`}
|
||||||
></div>
|
></div>
|
||||||
<span className="text-sm font-medium">
|
<span className="text-[13px] font-medium">
|
||||||
{proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
|
{proxyConfig.enabled ? t('proxy.service.running') : t('proxy.service.stopped')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-5">
|
||||||
{/* Start/Stop Button */}
|
{/* Start/Stop Button */}
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Button
|
<Button
|
||||||
@@ -387,7 +387,7 @@ print(response.choices[0].message.content)`;
|
|||||||
value={proxyConfig.api_key || ''}
|
value={proxyConfig.api_key || ''}
|
||||||
readOnly
|
readOnly
|
||||||
type={showKey ? 'text' : 'password'}
|
type={showKey ? 'text' : 'password'}
|
||||||
className="pr-10 font-mono text-sm"
|
className="pr-10 font-mono text-[13px]"
|
||||||
/>
|
/>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -454,10 +454,10 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Auto Start Toggle */}
|
{/* Auto Start Toggle */}
|
||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('proxy.config.auto_start')}</Label>
|
<Label>{t('proxy.config.auto_start')}</Label>
|
||||||
<p className="text-xs text-gray-500">{t('proxy.config.auto_start_desc')}</p>
|
<p className="text-xs text-muted-foreground">{t('proxy.config.auto_start_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={proxyConfig.auto_start}
|
checked={proxyConfig.auto_start}
|
||||||
@@ -470,7 +470,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Account Quota Context */}
|
{/* Account Quota Context */}
|
||||||
<Card>
|
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>Account Pool</CardTitle>
|
<CardTitle>Account Pool</CardTitle>
|
||||||
<CardDescription>Quota and routing context</CardDescription>
|
<CardDescription>Quota and routing context</CardDescription>
|
||||||
@@ -480,21 +480,21 @@ print(response.choices[0].message.content)`;
|
|||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium">Primary Account</p>
|
<p className="text-[13px] font-medium">Primary Account</p>
|
||||||
<p className="text-muted-foreground text-sm">{accounts[0].email}</p>
|
<p className="text-muted-foreground font-mono text-[13px]">{accounts[0].email}</p>
|
||||||
</div>
|
</div>
|
||||||
{accounts.length > 1 && (
|
{accounts.length > 1 && (
|
||||||
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
|
<Badge variant="outline">Pool Mode: Active ({accounts.length} accounts)</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="sm" asChild>
|
<Button variant="outline" size="sm" className="text-xs" asChild>
|
||||||
<Link to={'/accounts' as any}>Manage Accounts</Link>
|
<Link to={'/accounts' as any}>Manage Accounts</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
<div className="flex flex-col items-center gap-3 py-6 text-center">
|
||||||
<p className="text-muted-foreground text-sm">No accounts configured</p>
|
<p className="text-muted-foreground text-[13px]">No accounts configured</p>
|
||||||
<Button size="sm" asChild>
|
<Button size="sm" className="text-xs" asChild>
|
||||||
<Link to={'/accounts' as any}>Add Account</Link>
|
<Link to={'/accounts' as any}>Add Account</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@@ -505,16 +505,16 @@ print(response.choices[0].message.content)`;
|
|||||||
{proxyConfig.enabled ? (
|
{proxyConfig.enabled ? (
|
||||||
<>
|
<>
|
||||||
{/* Local Access Info Banner */}
|
{/* Local Access Info Banner */}
|
||||||
<div className="flex flex-col gap-2 rounded-md border border-blue-100 bg-blue-50 p-3 text-sm text-blue-900 dark:border-blue-900/50 dark:bg-blue-950/30 dark:text-blue-200">
|
<div className="flex flex-col gap-2 rounded-xl border border-blue-500/20 bg-blue-500/5 p-3 text-[13px] text-blue-200">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="font-semibold">{t('proxy.config.local_access')}</div>
|
<div className="font-semibold">{t('proxy.config.local_access')}</div>
|
||||||
<code className="rounded bg-blue-100 px-1.5 py-0.5 font-mono select-all dark:bg-blue-900/50">
|
<code className="rounded-md bg-blue-500/10 px-1.5 py-0.5 font-mono text-xs select-all">
|
||||||
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
|
http://{selectedIp || 'localhost'}:{proxyConfig.port}/v1
|
||||||
</code>
|
</code>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-7 w-7 text-blue-700 hover:bg-blue-100 dark:text-blue-300 dark:hover:bg-blue-900/50"
|
className="h-7 w-7 text-blue-300 hover:bg-blue-500/10"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
copyToClipboard(
|
copyToClipboard(
|
||||||
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
|
`http://${selectedIp || 'localhost'}:${proxyConfig.port}/v1`,
|
||||||
@@ -539,7 +539,7 @@ print(response.choices[0].message.content)`;
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{!proxyConfig.api_key && (
|
{!proxyConfig.api_key && (
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-amber-600 dark:text-amber-400">
|
<div className="flex items-center gap-2 text-xs font-medium text-amber-400">
|
||||||
{t('proxy.config.no_token_warning')}
|
{t('proxy.config.no_token_warning')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -572,11 +572,11 @@ print(response.choices[0].message.content)`;
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
return items.map((metric) => (
|
return items.map((metric) => (
|
||||||
<Card key={metric.label}>
|
<Card key={metric.label} className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardContent className="pt-6">
|
<CardContent className="pt-6">
|
||||||
<p className="text-muted-foreground text-xs">{metric.label}</p>
|
<p className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">{metric.label}</p>
|
||||||
<div className="mt-1 flex items-center gap-2">
|
<div className="mt-1 flex items-center gap-2">
|
||||||
<span className="text-2xl font-bold">
|
<span className="font-mono text-xl font-semibold tabular-nums">
|
||||||
{metric.value}
|
{metric.value}
|
||||||
{metric.suffix}
|
{metric.suffix}
|
||||||
</span>
|
</span>
|
||||||
@@ -588,11 +588,11 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col items-center justify-center gap-4 rounded-lg border border-dashed p-12 text-center">
|
<div className="flex flex-col items-center justify-center gap-4 rounded-xl border border-dashed border-white/[0.1] p-12 text-center">
|
||||||
<ServerOff className="text-muted-foreground h-12 w-12" />
|
<ServerOff className="text-muted-foreground/50 h-10 w-10" />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-lg font-medium">Proxy is not running</p>
|
<p className="text-base font-semibold">Proxy is not running</p>
|
||||||
<p className="text-muted-foreground text-sm">Start the proxy to enable local API access</p>
|
<p className="text-muted-foreground text-[13px]">Start the proxy to enable local API access</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
@@ -609,20 +609,20 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* Routing Tab */}
|
{/* Routing Tab */}
|
||||||
<TabsContent value="routing" className="space-y-5">
|
<TabsContent value="routing" className="space-y-5">
|
||||||
<Card>
|
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
|
<CardTitle>{t('proxy.mapping.title')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
|
<CardDescription>{t('proxy.mapping.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="rounded-lg border">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="grid grid-cols-3 gap-4 border-b bg-muted/50 p-3 text-xs font-medium text-muted-foreground">
|
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||||
<div>Requested Model</div>
|
<div>Requested Model</div>
|
||||||
<div>Resolved Model</div>
|
<div>Resolved Model</div>
|
||||||
<div>Provider</div>
|
<div>Provider</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 items-center p-3">
|
<div className="grid grid-cols-3 gap-4 items-center p-3 text-[13px]">
|
||||||
<div className="text-sm font-medium">Claude Sonnet 4.6 (Thinking)</div>
|
<div className="font-medium">Claude Sonnet 4.6 (Thinking)</div>
|
||||||
<Select
|
<Select
|
||||||
value={resolveAnthropicMappingValue(
|
value={resolveAnthropicMappingValue(
|
||||||
proxyConfig.anthropic_mapping,
|
proxyConfig.anthropic_mapping,
|
||||||
@@ -649,8 +649,8 @@ print(response.choices[0].message.content)`;
|
|||||||
</Select>
|
</Select>
|
||||||
<Badge variant="secondary">Anthropic</Badge>
|
<Badge variant="secondary">Anthropic</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-3 gap-4 items-center border-t p-3">
|
<div className="grid grid-cols-3 gap-4 items-center border-t border-white/[0.06] p-3 text-[13px]">
|
||||||
<div className="text-sm font-medium">Claude Opus 4.6 (Thinking)</div>
|
<div className="font-medium">Claude Opus 4.6 (Thinking)</div>
|
||||||
<Select
|
<Select
|
||||||
value={resolveAnthropicMappingValue(
|
value={resolveAnthropicMappingValue(
|
||||||
proxyConfig.anthropic_mapping,
|
proxyConfig.anthropic_mapping,
|
||||||
@@ -696,7 +696,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Current Mappings */}
|
{/* Current Mappings */}
|
||||||
<Card>
|
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
<CardTitle>{t('proxy.routing.currentMappings')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
<CardDescription>{t('proxy.routing.modelMapping')}</CardDescription>
|
||||||
@@ -706,10 +706,10 @@ print(response.choices[0].message.content)`;
|
|||||||
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
const mappingEntries = Object.entries(proxyConfig.anthropic_mapping || {});
|
||||||
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
const customEntries = Object.entries(proxyConfig.custom_mapping || {});
|
||||||
return mappingEntries.length === 0 && customEntries.length === 0 ? (
|
return mappingEntries.length === 0 && customEntries.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">{t('proxy.routing.noMapping')}</p>
|
<p className="text-[13px] text-muted-foreground">{t('proxy.routing.noMapping')}</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div className="grid grid-cols-3 gap-4 border-b bg-muted/50 p-3 text-xs font-medium text-muted-foreground">
|
<div className="grid grid-cols-3 gap-4 border-b border-white/[0.06] bg-white/[0.02] p-3 text-[11px] font-medium tracking-wider uppercase text-muted-foreground">
|
||||||
<div>{t('proxy.mapping.maps_to')}</div>
|
<div>{t('proxy.mapping.maps_to')}</div>
|
||||||
<div>{t('settings.modelMapping.targetGemini')}</div>
|
<div>{t('settings.modelMapping.targetGemini')}</div>
|
||||||
<div>Provider</div>
|
<div>Provider</div>
|
||||||
@@ -717,7 +717,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{mappingEntries.map(([key, value]) => (
|
{mappingEntries.map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="grid grid-cols-3 gap-4 items-center border-b p-3 last:border-0"
|
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
|
||||||
>
|
>
|
||||||
<div className="font-mono text-xs">{key}</div>
|
<div className="font-mono text-xs">{key}</div>
|
||||||
<div className="font-mono text-xs">{value}</div>
|
<div className="font-mono text-xs">{value}</div>
|
||||||
@@ -727,7 +727,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{customEntries.map(([key, value]) => (
|
{customEntries.map(([key, value]) => (
|
||||||
<div
|
<div
|
||||||
key={key}
|
key={key}
|
||||||
className="grid grid-cols-3 gap-4 items-center border-b p-3 last:border-0"
|
className="grid grid-cols-3 gap-4 items-center border-b border-white/[0.06] p-3 last:border-0"
|
||||||
>
|
>
|
||||||
<div className="font-mono text-xs">{key}</div>
|
<div className="font-mono text-xs">{key}</div>
|
||||||
<div className="font-mono text-xs">{value}</div>
|
<div className="font-mono text-xs">{value}</div>
|
||||||
@@ -741,21 +741,21 @@ print(response.choices[0].message.content)`;
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Upstream Proxy Status */}
|
{/* Upstream Proxy Status */}
|
||||||
<Card>
|
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
<CardTitle>{t('proxy.routing.upstreamProxy')}</CardTitle>
|
||||||
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
<CardDescription>{t('proxy.routing.upstreamProxy')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
||||||
<span className="text-sm text-muted-foreground">{t('proxy.routing.status')}</span>
|
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.status')}</span>
|
||||||
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
|
<Badge variant={proxyConfig.upstream_proxy?.enabled ? 'default' : 'secondary'}>
|
||||||
{proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')}
|
{proxyConfig.upstream_proxy?.enabled ? t('proxy.routing.enabled') : t('proxy.routing.disabled')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
{proxyConfig.upstream_proxy?.enabled && (
|
{proxyConfig.upstream_proxy?.enabled && (
|
||||||
<div className="flex items-center justify-between rounded-lg border p-3">
|
<div className="flex items-center justify-between rounded-xl border border-white/[0.06] p-3">
|
||||||
<span className="text-sm text-muted-foreground">{t('proxy.routing.url')}</span>
|
<span className="text-[13px] text-muted-foreground">{t('proxy.routing.url')}</span>
|
||||||
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
|
<span className="font-mono text-xs">{proxyConfig.upstream_proxy?.url || '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -765,7 +765,7 @@ print(response.choices[0].message.content)`;
|
|||||||
|
|
||||||
{/* API Docs Tab */}
|
{/* API Docs Tab */}
|
||||||
<TabsContent value="api-docs" className="space-y-5">
|
<TabsContent value="api-docs" className="space-y-5">
|
||||||
<Card>
|
<Card className="border border-white/[0.06] bg-card shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
<Code size={20} />
|
<Code size={20} />
|
||||||
@@ -791,9 +791,9 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Model Selector & Copy API ID */}
|
{/* Model Selector & Copy API ID */}
|
||||||
<div className="flex flex-col gap-4 border-b border-gray-200 pb-4 sm:flex-row sm:items-center sm:justify-between dark:border-gray-700">
|
<div className="flex flex-col gap-4 border-b border-white/[0.06] pb-4 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Label className="text-sm font-medium">
|
<Label className="text-[13px] font-medium">
|
||||||
Model{' '}
|
Model{' '}
|
||||||
<span className="text-muted-foreground ml-1 font-normal">
|
<span className="text-muted-foreground ml-1 font-normal">
|
||||||
({availableModelIds.length} available)
|
({availableModelIds.length} available)
|
||||||
@@ -836,7 +836,7 @@ print(response.choices[0].message.content)`;
|
|||||||
{/* Collapsible Code Examples */}
|
{/* Collapsible Code Examples */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm font-medium">Code Examples</Label>
|
<Label className="text-[13px] font-medium">Code Examples</Label>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -851,9 +851,9 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* cURL */}
|
{/* cURL */}
|
||||||
<div className="rounded-lg border">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-sm font-medium hover:bg-muted/50"
|
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
|
||||||
onClick={() => setShowCurl(!showCurl)}
|
onClick={() => setShowCurl(!showCurl)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -876,16 +876,16 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showCurl && (
|
{showCurl && (
|
||||||
<pre className="overflow-x-auto border-t bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||||
{getCurlExample(activeModelTab)}
|
{getCurlExample(activeModelTab)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Python */}
|
{/* Python */}
|
||||||
<div className="rounded-lg border">
|
<div className="overflow-hidden rounded-xl border border-white/[0.06]">
|
||||||
<div
|
<div
|
||||||
className="flex w-full cursor-pointer items-center justify-between p-3 text-sm font-medium hover:bg-muted/50"
|
className="flex w-full cursor-pointer items-center justify-between p-3 text-[13px] font-medium hover:bg-white/[0.02] transition-colors"
|
||||||
onClick={() => setShowPython(!showPython)}
|
onClick={() => setShowPython(!showPython)}
|
||||||
>
|
>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
@@ -908,7 +908,7 @@ print(response.choices[0].message.content)`;
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{showPython && (
|
{showPython && (
|
||||||
<pre className="overflow-x-auto border-t bg-gray-900 p-3 font-mono text-xs whitespace-pre-wrap text-gray-100">
|
<pre className="overflow-x-auto border-t border-white/[0.06] bg-card p-3 font-mono text-xs whitespace-pre-wrap text-foreground/90">
|
||||||
{getPythonExample(activeModelTab)}
|
{getPythonExample(activeModelTab)}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -79,8 +79,8 @@ function SettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
<div className="container mx-auto max-w-4xl space-y-5 p-6">
|
||||||
<div>
|
<div>
|
||||||
<h2 className="text-3xl font-bold tracking-tight">{t('settings.title')}</h2>
|
<h2 className="text-lg font-semibold tracking-tight">{t('settings.title')}</h2>
|
||||||
<p className="text-muted-foreground mt-1">{t('settings.description')}</p>
|
<p className="text-muted-foreground mt-1 text-[13px]">{t('settings.description')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs defaultValue="general" className="w-full">
|
<Tabs defaultValue="general" className="w-full">
|
||||||
@@ -92,16 +92,16 @@ function SettingsPage() {
|
|||||||
|
|
||||||
{/* --- GENERAL TAB --- */}
|
{/* --- GENERAL TAB --- */}
|
||||||
<TabsContent value="general" className="space-y-5">
|
<TabsContent value="general" className="space-y-5">
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
<CardTitle>{t('settings.appearance.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
<CardDescription>{t('settings.appearance.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-5">
|
||||||
<div className="flex items-center justify-between space-x-2">
|
<div className="flex items-center justify-between space-x-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label htmlFor="dark-mode">{t('settings.darkMode')}</Label>
|
<Label htmlFor="dark-mode">{t('settings.darkMode')}</Label>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t('settings.darkModeDescription')}
|
{t('settings.darkModeDescription')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -139,8 +139,8 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Account Settings Card */}
|
{/* Account Settings Card */}
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.account.title')}</CardTitle>
|
<CardTitle>{t('settings.account.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.account.description')}</CardDescription>
|
<CardDescription>{t('settings.account.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -149,7 +149,7 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.account.auto_refresh')}</Label>
|
<Label>{t('settings.account.auto_refresh')}</Label>
|
||||||
<p className="text-xs text-gray-500">{t('settings.account.auto_refresh_desc')}</p>
|
<p className="text-muted-foreground text-xs">{t('settings.account.auto_refresh_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.auto_refresh || false}
|
checked={config?.auto_refresh || false}
|
||||||
@@ -165,7 +165,7 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.account.auto_sync')}</Label>
|
<Label>{t('settings.account.auto_sync')}</Label>
|
||||||
<p className="text-xs text-gray-500">{t('settings.account.auto_sync_desc')}</p>
|
<p className="text-muted-foreground text-xs">{t('settings.account.auto_sync_desc')}</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={config?.auto_sync || false}
|
checked={config?.auto_sync || false}
|
||||||
@@ -180,8 +180,8 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{isAutoStartSupported && (
|
{isAutoStartSupported && (
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
<CardTitle>{t('settings.startup.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
<CardDescription>{t('settings.startup.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -189,7 +189,7 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.startup.auto_startup')}</Label>
|
<Label>{t('settings.startup.auto_startup')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t('settings.startup.auto_startup_desc')}
|
{t('settings.startup.auto_startup_desc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -211,13 +211,13 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.about.title')}</CardTitle>
|
<CardTitle>{t('settings.about.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.about.description')}</CardDescription>
|
<CardDescription>{t('settings.about.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
<div className="grid grid-cols-2 gap-4 text-[13px]">
|
||||||
<div className="text-muted-foreground">{t('settings.version')}</div>
|
<div className="text-muted-foreground">{t('settings.version')}</div>
|
||||||
<div className="font-medium">{appVersion || t('common.unknown')}</div>
|
<div className="font-medium">{appVersion || t('common.unknown')}</div>
|
||||||
|
|
||||||
@@ -230,7 +230,7 @@ function SettingsPage() {
|
|||||||
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
<div className="text-muted-foreground">{t('action.openLogs')}</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => openLogDirectory()}
|
onClick={() => openLogDirectory()}
|
||||||
className="flex items-center gap-2 font-medium text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300"
|
className="flex items-center gap-2 font-medium text-accent hover:text-accent/80 transition-colors"
|
||||||
>
|
>
|
||||||
<FolderOpen className="h-4 w-4" />
|
<FolderOpen className="h-4 w-4" />
|
||||||
<span>{t('settings.openLogDir')}</span>
|
<span>{t('settings.openLogDir')}</span>
|
||||||
@@ -240,8 +240,8 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Privacy & Error Reporting Card */}
|
{/* Privacy & Error Reporting Card */}
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
<CardTitle>{t('settings.privacy.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
<CardDescription>{t('settings.privacy.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -249,7 +249,7 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.privacy.error_reporting')}</Label>
|
<Label>{t('settings.privacy.error_reporting')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t('settings.privacy.error_reporting_desc')}
|
{t('settings.privacy.error_reporting_desc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -267,8 +267,8 @@ function SettingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Notifications Card */}
|
{/* Notifications Card */}
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
<CardTitle>{t('settings.notifications.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
<CardDescription>{t('settings.notifications.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -276,7 +276,7 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.notifications.quotaAlert')}</Label>
|
<Label>{t('settings.notifications.quotaAlert')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t('settings.notifications.quotaAlertDesc')}
|
{t('settings.notifications.quotaAlertDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -300,7 +300,7 @@ function SettingsPage() {
|
|||||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label>{t('settings.notifications.quotaThreshold')}</Label>
|
<Label>{t('settings.notifications.quotaThreshold')}</Label>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-muted-foreground text-xs">
|
||||||
{t('settings.notifications.quotaThresholdDesc')}
|
{t('settings.notifications.quotaThresholdDesc')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -327,7 +327,7 @@ function SettingsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="w-16 rounded-md border bg-transparent px-2 py-1 text-center text-sm"
|
className="w-16 rounded-lg border border-white/[0.08] bg-muted/50 px-2 py-1 text-center text-[13px] text-foreground focus:outline-none focus:border-accent/50"
|
||||||
/>
|
/>
|
||||||
<span className="text-muted-foreground text-sm">%</span>
|
<span className="text-muted-foreground text-sm">%</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -343,8 +343,8 @@ function SettingsPage() {
|
|||||||
|
|
||||||
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
{/* --- PROXY TAB (Upstream Proxy Config Only) --- */}
|
||||||
<TabsContent value="proxy" className="space-y-5">
|
<TabsContent value="proxy" className="space-y-5">
|
||||||
<Card>
|
<Card className="bg-transparent border-0 shadow-none rounded-none border-b border-white/5 last:border-0 pb-6">
|
||||||
<CardHeader>
|
<CardHeader className="px-0 pt-4">
|
||||||
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
<CardTitle>{t('settings.proxy.title')}</CardTitle>
|
||||||
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
<CardDescription>{t('settings.proxy.description')}</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import { createFileRoute } from '@tanstack/react-router';
|
import { createFileRoute } from '@tanstack/react-router';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { ResponsiveLine } from '@nivo/line';
|
||||||
import { ResponsiveBar } from '@nivo/bar';
|
import { ResponsiveBar } from '@nivo/bar';
|
||||||
import { ResponsivePie } from '@nivo/pie';
|
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
@@ -19,19 +18,16 @@ import { ChartCard } from '@/components/usage/ChartCard';
|
|||||||
import {
|
import {
|
||||||
COLOR_PROMPT,
|
COLOR_PROMPT,
|
||||||
COLOR_COMPLETION,
|
COLOR_COMPLETION,
|
||||||
COLOR_TOTAL,
|
|
||||||
PIE_COLORS,
|
PIE_COLORS,
|
||||||
BarTooltip,
|
LineTooltip,
|
||||||
PieTooltip,
|
|
||||||
} from '@/components/usage/NivoTooltip';
|
} from '@/components/usage/NivoTooltip';
|
||||||
import { nivoTheme } from '@/lib/nivo-theme';
|
import { nivoTheme } from '@/lib/nivo-theme';
|
||||||
import {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
ArrowUpRight,
|
|
||||||
ArrowDownRight,
|
|
||||||
Layers,
|
Layers,
|
||||||
Loader2,
|
Loader2,
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
|
Radio,
|
||||||
Zap,
|
Zap,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
@@ -61,50 +57,84 @@ interface ModelBucket {
|
|||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
function getTimeRange(r: TimeRange): { start: number; end: number } {
|
function getTimeRange(r: TimeRange): { start: number; end: number } {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (r === '24h') {
|
const ms: Record<TimeRange, number> = {
|
||||||
return { start: now - 24 * 60 * 60 * 1000, end: now };
|
'24h': 24 * 60 * 60 * 1000,
|
||||||
}
|
'7d': 7 * 24 * 60 * 60 * 1000,
|
||||||
if (r === '7d') {
|
'30d': 30 * 24 * 60 * 60 * 1000,
|
||||||
return { start: now - 7 * 24 * 60 * 60 * 1000, end: now };
|
};
|
||||||
}
|
return { start: now - ms[r], end: now };
|
||||||
return { start: now - 30 * 24 * 60 * 60 * 1000, end: now };
|
}
|
||||||
|
|
||||||
|
function getPreviousTimeRange(r: TimeRange): { start: number; end: number } {
|
||||||
|
const { start, end } = getTimeRange(r);
|
||||||
|
const duration = end - start;
|
||||||
|
return { start: start - duration, end: start };
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBucketLabel(bucket: string, range: TimeRange): string {
|
function formatBucketLabel(bucket: string, range: TimeRange): string {
|
||||||
if (range === '24h') {
|
if (range === '24h') {
|
||||||
// bucket is "YYYY-MM-DD HH:00"
|
|
||||||
const parts = bucket.split(' ');
|
const parts = bucket.split(' ');
|
||||||
if (parts.length === 2) {
|
return parts.length === 2 ? parts[1] : bucket;
|
||||||
return parts[1]; // "HH:00"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (range === '7d') {
|
|
||||||
// bucket is "YYYY-MM-DD"
|
|
||||||
const d = new Date(bucket + 'T00:00:00');
|
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
||||||
}
|
|
||||||
// 30d
|
|
||||||
const d = new Date(bucket + 'T00:00:00');
|
const d = new Date(bucket + 'T00:00:00');
|
||||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function barColor(bar: any): string {
|
function formatNumber(n: number): string {
|
||||||
if (bar.id === 'Prompt') return COLOR_PROMPT;
|
if (n >= 1_000_000) {
|
||||||
if (bar.id === 'Completion') return COLOR_COMPLETION;
|
return `${(n / 1_000_000).toFixed(1)}M`;
|
||||||
if (bar.id === 'Total') return COLOR_TOTAL;
|
}
|
||||||
return 'var(--chart-3)';
|
if (n >= 1_000) {
|
||||||
|
return `${(n / 1_000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return n.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function calcTrend(
|
||||||
|
current: number,
|
||||||
|
previous: number,
|
||||||
|
): { value: number; direction: 'up' | 'down' | 'neutral' } {
|
||||||
|
if (previous === 0 && current === 0) {
|
||||||
|
return { value: 0, direction: 'neutral' };
|
||||||
|
}
|
||||||
|
if (previous === 0) {
|
||||||
|
return { value: 100, direction: 'up' };
|
||||||
|
}
|
||||||
|
const pct = ((current - previous) / previous) * 100;
|
||||||
|
if (Math.abs(pct) < 0.5) {
|
||||||
|
return { value: 0, direction: 'neutral' };
|
||||||
|
}
|
||||||
|
return { value: Math.abs(pct), direction: pct > 0 ? 'up' : 'down' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Aggregation helpers */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
function sumBuckets(data: UsageBucket[]) {
|
||||||
|
return data.reduce(
|
||||||
|
(acc, row) => ({
|
||||||
|
promptTokens: acc.promptTokens + row.promptTokens,
|
||||||
|
completionTokens: acc.completionTokens + row.completionTokens,
|
||||||
|
totalTokens: acc.totalTokens + row.totalTokens,
|
||||||
|
requests: acc.requests + row.requests,
|
||||||
|
}),
|
||||||
|
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
/* Main Component */
|
/* Main Component */
|
||||||
/* ------------------------------------------------------------------ */
|
/* ------------------------------------------------------------------ */
|
||||||
function UsagePage() {
|
function UsagePage() {
|
||||||
const { t } = useTranslation();
|
|
||||||
const [range, setRange] = useState<TimeRange>('7d');
|
const [range, setRange] = useState<TimeRange>('7d');
|
||||||
const ALL_ACCOUNTS = '__all__';
|
const ALL_ACCOUNTS = '__all__';
|
||||||
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
const [selectedAccountId, setSelectedAccountId] = useState<string>(ALL_ACCOUNTS);
|
||||||
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
const { data: accounts, isLoading: accountsLoading } = useCloudAccounts();
|
||||||
|
|
||||||
|
const accountFilter =
|
||||||
|
selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId;
|
||||||
|
|
||||||
|
/* ───── Current period data ───── */
|
||||||
const {
|
const {
|
||||||
data: dailyData,
|
data: dailyData,
|
||||||
isLoading: dailyLoading,
|
isLoading: dailyLoading,
|
||||||
@@ -114,10 +144,27 @@ function UsagePage() {
|
|||||||
queryKey: ['usage', 'day', range, selectedAccountId],
|
queryKey: ['usage', 'day', range, selectedAccountId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { start, end } = getTimeRange(range);
|
const { start, end } = getTimeRange(range);
|
||||||
return ipc.client.usage.getUsageByDay({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
return ipc.client.usage.getUsageByDay({
|
||||||
|
accountId: accountFilter,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
refetchInterval: 30000,
|
refetchInterval: 15_000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: prevDailyData } = useQuery<UsageBucket[]>({
|
||||||
|
queryKey: ['usage', 'day', 'prev', range, selectedAccountId],
|
||||||
|
queryFn: () => {
|
||||||
|
const { start, end } = getPreviousTimeRange(range);
|
||||||
|
return ipc.client.usage.getUsageByDay({
|
||||||
|
accountId: accountFilter,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
|
},
|
||||||
staleTime: 60_000,
|
staleTime: 60_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,11 +177,15 @@ function UsagePage() {
|
|||||||
queryKey: ['usage', 'model', range, selectedAccountId],
|
queryKey: ['usage', 'model', range, selectedAccountId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { start, end } = getTimeRange(range);
|
const { start, end } = getTimeRange(range);
|
||||||
return ipc.client.usage.getUsageByModel({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
return ipc.client.usage.getUsageByModel({
|
||||||
|
accountId: accountFilter,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
refetchInterval: 30000,
|
refetchInterval: 15_000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 60_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -146,99 +197,148 @@ function UsagePage() {
|
|||||||
queryKey: ['usage', 'hour', range, selectedAccountId],
|
queryKey: ['usage', 'hour', range, selectedAccountId],
|
||||||
queryFn: () => {
|
queryFn: () => {
|
||||||
const { start, end } = getTimeRange(range);
|
const { start, end } = getTimeRange(range);
|
||||||
return ipc.client.usage.getUsageByHour({ accountId: selectedAccountId === ALL_ACCOUNTS ? undefined : selectedAccountId, start, end });
|
return ipc.client.usage.getUsageByHour({
|
||||||
|
accountId: accountFilter,
|
||||||
|
start,
|
||||||
|
end,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
enabled: range === '24h',
|
enabled: range === '24h',
|
||||||
refetchInterval: 30000,
|
refetchInterval: 15_000,
|
||||||
refetchIntervalInBackground: false,
|
refetchIntervalInBackground: false,
|
||||||
staleTime: 60_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
/* -------------------------- Derived data -------------------------- */
|
/* ───── Derived data ───── */
|
||||||
const totals = useMemo(() => {
|
const totals = useMemo(() => sumBuckets(dailyData || []), [dailyData]);
|
||||||
const source = dailyData || [];
|
const prevTotals = useMemo(
|
||||||
return source.reduce(
|
() => sumBuckets(prevDailyData || []),
|
||||||
(acc, row) => ({
|
[prevDailyData],
|
||||||
promptTokens: acc.promptTokens + row.promptTokens,
|
);
|
||||||
completionTokens: acc.completionTokens + row.completionTokens,
|
|
||||||
totalTokens: acc.totalTokens + row.totalTokens,
|
|
||||||
requests: acc.requests + row.requests,
|
|
||||||
}),
|
|
||||||
{ promptTokens: 0, completionTokens: 0, totalTokens: 0, requests: 0 },
|
|
||||||
);
|
|
||||||
}, [dailyData]);
|
|
||||||
|
|
||||||
const promptRatio = useMemo(() => {
|
const promptRatio = totals.totalTokens
|
||||||
if (totals.totalTokens === 0) return 0;
|
? Math.round((totals.promptTokens / totals.totalTokens) * 100)
|
||||||
return Math.round((totals.promptTokens / totals.totalTokens) * 100);
|
: 0;
|
||||||
}, [totals]);
|
const completionRatio = totals.totalTokens
|
||||||
|
? Math.round((totals.completionTokens / totals.totalTokens) * 100)
|
||||||
|
: 0;
|
||||||
|
const avgReqSize = totals.requests
|
||||||
|
? Math.round(totals.totalTokens / totals.requests)
|
||||||
|
: 0;
|
||||||
|
|
||||||
const completionRatio = useMemo(() => {
|
const sparklineTotal = useMemo(
|
||||||
if (totals.totalTokens === 0) return 0;
|
() => (dailyData || []).map((d) => d.totalTokens),
|
||||||
return Math.round((totals.completionTokens / totals.totalTokens) * 100);
|
[dailyData],
|
||||||
}, [totals]);
|
);
|
||||||
|
const sparklinePrompt = useMemo(
|
||||||
|
() => (dailyData || []).map((d) => d.promptTokens),
|
||||||
|
[dailyData],
|
||||||
|
);
|
||||||
|
const sparklineCompletion = useMemo(
|
||||||
|
() => (dailyData || []).map((d) => d.completionTokens),
|
||||||
|
[dailyData],
|
||||||
|
);
|
||||||
|
const sparklineRequests = useMemo(
|
||||||
|
() => (dailyData || []).map((d) => d.requests),
|
||||||
|
[dailyData],
|
||||||
|
);
|
||||||
|
|
||||||
const chartData = useMemo(() => {
|
// Area chart — Nivo line format
|
||||||
return (dailyData || []).map((d) => ({
|
const areaChartData = useMemo(() => {
|
||||||
name: formatBucketLabel(d.bucket, range),
|
const source = range === '24h' ? hourlyData || [] : dailyData || [];
|
||||||
Prompt: d.promptTokens,
|
return [
|
||||||
Completion: d.completionTokens,
|
{
|
||||||
}));
|
id: 'Prompt',
|
||||||
}, [dailyData, range]);
|
color: COLOR_PROMPT,
|
||||||
|
data: source.map((d) => ({
|
||||||
|
x: formatBucketLabel(d.bucket, range),
|
||||||
|
y: d.promptTokens,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'Completion',
|
||||||
|
color: COLOR_COMPLETION,
|
||||||
|
data: source.map((d) => ({
|
||||||
|
x: formatBucketLabel(d.bucket, range),
|
||||||
|
y: d.completionTokens,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [dailyData, hourlyData, range]);
|
||||||
|
|
||||||
const pieData = useMemo(() => {
|
// Model ranking bar data
|
||||||
return (modelData || []).map((d) => ({
|
const modelBarData = useMemo(() => {
|
||||||
id: d.model,
|
if (!modelData?.length) {
|
||||||
value: d.totalTokens,
|
return [];
|
||||||
}));
|
}
|
||||||
|
return [...modelData]
|
||||||
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||||
|
.slice(0, 8)
|
||||||
|
.map((m) => ({
|
||||||
|
model:
|
||||||
|
m.model.length > 22 ? m.model.slice(0, 22) + '…' : m.model,
|
||||||
|
tokens: m.totalTokens,
|
||||||
|
}));
|
||||||
}, [modelData]);
|
}, [modelData]);
|
||||||
|
|
||||||
const hourlyChartData = useMemo(() => {
|
// Top models mini-list
|
||||||
return (hourlyData || []).map((d) => ({
|
const topModels = useMemo(() => {
|
||||||
name: formatBucketLabel(d.bucket, '24h'),
|
if (!modelData?.length) {
|
||||||
Prompt: d.promptTokens,
|
return [];
|
||||||
Completion: d.completionTokens,
|
}
|
||||||
Total: d.totalTokens,
|
return [...modelData]
|
||||||
}));
|
.sort((a, b) => b.totalTokens - a.totalTokens)
|
||||||
}, [hourlyData]);
|
.slice(0, 5);
|
||||||
|
}, [modelData]);
|
||||||
|
|
||||||
const hourlyTickValues = useMemo(() => {
|
const isAreaEmpty =
|
||||||
return hourlyChartData.filter((_, i) => i % 3 === 0).map((d) => d.name);
|
range === '24h'
|
||||||
}, [hourlyChartData]);
|
? !hourlyLoading && !hourlyError && !hourlyData?.length
|
||||||
|
: !dailyLoading && !dailyError && !dailyData?.length;
|
||||||
|
const areaLoading = range === '24h' ? hourlyLoading : dailyLoading;
|
||||||
|
const areaError = range === '24h' ? hourlyError : dailyError;
|
||||||
|
const isModelEmpty = !modelLoading && !modelError && !modelData?.length;
|
||||||
|
|
||||||
const isDailyEmpty = !dailyLoading && !dailyError && (!dailyData || dailyData.length === 0);
|
/* ───── Render ───── */
|
||||||
const isModelEmpty = !modelLoading && !modelError && (!modelData || modelData.length === 0);
|
|
||||||
const isHourlyEmpty = !hourlyLoading && !hourlyError && (!hourlyData || hourlyData.length === 0);
|
|
||||||
|
|
||||||
/* ----------------------------- Render ----------------------------- */
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col overflow-auto">
|
<div className="flex h-full flex-col overflow-auto">
|
||||||
{/* Header */}
|
{/* ─── Header ─── */}
|
||||||
<div className="border-border/40 flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-4 border-b border-white/[0.06] px-6 py-5 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-foreground text-xl font-bold tracking-tight">{t('usage.title')}</h1>
|
<div className="flex items-center gap-2">
|
||||||
<p className="text-muted-foreground mt-0.5 text-sm">{t('usage.description')}</p>
|
<h1 className="text-foreground text-lg font-semibold tracking-tight">
|
||||||
|
Usage Analytics
|
||||||
|
</h1>
|
||||||
|
<div className="flex items-center gap-1.5 rounded-full border border-emerald-500/20 bg-emerald-500/10 px-2 py-0.5">
|
||||||
|
<Radio className="h-2.5 w-2.5 animate-pulse text-emerald-400" />
|
||||||
|
<span className="text-[10px] font-medium text-emerald-400">
|
||||||
|
Live
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground mt-0.5 text-[13px]">
|
||||||
|
Token consumption and model distribution
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Account filter */}
|
|
||||||
<Select
|
<Select
|
||||||
value={selectedAccountId}
|
value={selectedAccountId}
|
||||||
onValueChange={(value) => setSelectedAccountId(value)}
|
onValueChange={setSelectedAccountId}
|
||||||
disabled={accountsLoading}
|
disabled={accountsLoading}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="h-9 w-[200px] text-xs">
|
<SelectTrigger className="h-8 w-[180px] text-xs">
|
||||||
{accountsLoading ? (
|
{accountsLoading ? (
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" />
|
||||||
{t('common.loading')}
|
Loading…
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<SelectValue placeholder={t('usage.allAccounts')} />
|
<SelectValue placeholder="All accounts" />
|
||||||
)}
|
)}
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value={ALL_ACCOUNTS}>{t('usage.allAccounts')}</SelectItem>
|
<SelectItem value={ALL_ACCOUNTS}>All accounts</SelectItem>
|
||||||
{accounts?.map((acc) => (
|
{accounts?.map((acc) => (
|
||||||
<SelectItem key={acc.id} value={acc.id}>
|
<SelectItem key={acc.id} value={acc.id}>
|
||||||
{acc.email}
|
{acc.email}
|
||||||
@@ -247,246 +347,316 @@ function UsagePage() {
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
{/* Time range tabs */}
|
<Tabs
|
||||||
<Tabs value={range} onValueChange={(v) => setRange(v as TimeRange)}>
|
value={range}
|
||||||
<TabsList className="h-9">
|
onValueChange={(v) => setRange(v as TimeRange)}
|
||||||
|
>
|
||||||
|
<TabsList className="h-8">
|
||||||
<TabsTrigger value="24h" className="px-3 text-xs">
|
<TabsTrigger value="24h" className="px-3 text-xs">
|
||||||
{t('usage.range24h')}
|
24h
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="7d" className="px-3 text-xs">
|
<TabsTrigger value="7d" className="px-3 text-xs">
|
||||||
{t('usage.range7d')}
|
7d
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="30d" className="px-3 text-xs">
|
<TabsTrigger value="30d" className="px-3 text-xs">
|
||||||
{t('usage.range30d')}
|
30d
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 space-y-5 px-6 py-5">
|
<div className="flex-1 space-y-4 px-6 py-5">
|
||||||
{/* Summary Stats */}
|
{/* ─── Stat cards ─── */}
|
||||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('usage.totalTokens')}
|
label="Total Tokens"
|
||||||
value={totals.totalTokens}
|
value={formatNumber(totals.totalTokens)}
|
||||||
icon={Zap}
|
icon={Zap}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="amber"
|
accent="amber"
|
||||||
|
trend={
|
||||||
|
prevDailyData
|
||||||
|
? calcTrend(totals.totalTokens, prevTotals.totalTokens)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklineTotal}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('usage.promptTokens')}
|
label="Prompt Tokens"
|
||||||
value={totals.promptTokens}
|
value={formatNumber(totals.promptTokens)}
|
||||||
icon={MessageSquare}
|
icon={MessageSquare}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="blue"
|
accent="blue"
|
||||||
|
trend={
|
||||||
|
prevDailyData
|
||||||
|
? calcTrend(totals.promptTokens, prevTotals.promptTokens)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklinePrompt}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('usage.completionTokens')}
|
label="Completion Tokens"
|
||||||
value={totals.completionTokens}
|
value={formatNumber(totals.completionTokens)}
|
||||||
icon={Activity}
|
icon={Activity}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="green"
|
accent="green"
|
||||||
|
trend={
|
||||||
|
prevDailyData
|
||||||
|
? calcTrend(
|
||||||
|
totals.completionTokens,
|
||||||
|
prevTotals.completionTokens,
|
||||||
|
)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklineCompletion}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label={t('usage.totalRequests')}
|
label="Total Requests"
|
||||||
value={totals.requests}
|
value={totals.requests.toLocaleString()}
|
||||||
icon={Layers}
|
icon={Layers}
|
||||||
isLoading={dailyLoading}
|
isLoading={dailyLoading}
|
||||||
accent="slate"
|
accent="slate"
|
||||||
|
trend={
|
||||||
|
prevDailyData
|
||||||
|
? calcTrend(totals.requests, prevTotals.requests)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
sparkline={sparklineRequests}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Trend indicators row */}
|
{/* ─── Prompt/Completion ratio bar + top models ─── */}
|
||||||
{!dailyLoading && totals.totalTokens > 0 && (
|
{!dailyLoading && totals.totalTokens > 0 && (
|
||||||
<div className="flex flex-wrap items-center gap-4">
|
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
<div className="col-span-1 flex flex-col gap-3 rounded-xl border border-white/[0.06] bg-card p-4 lg:col-span-2">
|
||||||
<ArrowUpRight className="h-3.5 w-3.5 text-blue-400" />
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">{t('usage.promptLabel')}:</span>
|
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
||||||
<span className="text-foreground font-mono font-medium">{promptRatio}%</span>
|
Prompt / Completion
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">
|
||||||
|
{formatNumber(totals.totalTokens)} total
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-l-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${promptRatio}%`,
|
||||||
|
backgroundColor: COLOR_PROMPT,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className="h-full rounded-r-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${completionRatio}%`,
|
||||||
|
backgroundColor: COLOR_COMPLETION,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: COLOR_PROMPT }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">Prompt</span>
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{promptRatio}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 rounded-full"
|
||||||
|
style={{ backgroundColor: COLOR_COMPLETION }}
|
||||||
|
/>
|
||||||
|
<span className="text-muted-foreground">Completion</span>
|
||||||
|
<span className="font-mono font-semibold text-foreground">
|
||||||
|
{completionRatio}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Avg/req:{' '}
|
||||||
|
<span className="font-mono font-medium text-foreground">
|
||||||
|
{formatNumber(avgReqSize)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs">
|
|
||||||
<ArrowDownRight className="h-3.5 w-3.5 text-emerald-400" />
|
{/* Top models */}
|
||||||
<span className="text-muted-foreground">{t('usage.completionLabel')}:</span>
|
<div className="rounded-xl border border-white/[0.06] bg-card p-4">
|
||||||
<span className="text-foreground font-mono font-medium">{completionRatio}%</span>
|
<div className="mb-3 flex items-center justify-between">
|
||||||
</div>
|
<span className="text-muted-foreground text-[11px] font-medium uppercase tracking-wider">
|
||||||
<div className="bg-border/40 h-3 w-px" />
|
Top Models
|
||||||
<div className="text-muted-foreground text-xs">
|
</span>
|
||||||
{t('usage.requestsLabel')}:{' '}
|
<span className="text-[10px] text-muted-foreground">
|
||||||
<span className="text-foreground font-mono font-medium">
|
tokens
|
||||||
{totals.requests.toLocaleString()}
|
</span>
|
||||||
</span>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{topModels.map((m, i) => {
|
||||||
|
const max = topModels[0]?.totalTokens || 1;
|
||||||
|
const pct = Math.max(8, (m.totalTokens / max) * 100);
|
||||||
|
return (
|
||||||
|
<div key={m.model} className="group">
|
||||||
|
<div className="mb-0.5 flex items-center justify-between">
|
||||||
|
<span className="max-w-[140px] truncate text-xs text-muted-foreground transition-colors group-hover:text-foreground">
|
||||||
|
{m.model}
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[11px] font-medium text-foreground tabular-nums">
|
||||||
|
{formatNumber(m.totalTokens)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-1 w-full overflow-hidden rounded-full bg-white/[0.04]">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-500"
|
||||||
|
style={{
|
||||||
|
width: `${pct}%`,
|
||||||
|
backgroundColor:
|
||||||
|
PIE_COLORS[i % PIE_COLORS.length],
|
||||||
|
opacity: 0.7,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{topModels.length === 0 && (
|
||||||
|
<p className="py-4 text-center text-xs text-muted-foreground">
|
||||||
|
No data
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Charts */}
|
{/* ─── Charts — Area + Model ranking ─── */}
|
||||||
<div className="grid grid-cols-1 gap-4 xl:grid-cols-2">
|
<div className="grid grid-cols-1 gap-3 xl:grid-cols-5">
|
||||||
{/* Daily Usage Bar Chart */}
|
<div className="xl:col-span-3">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('usage.dailyUsage')}
|
title={range === '24h' ? 'Hourly Usage' : 'Daily Usage'}
|
||||||
isLoading={dailyLoading}
|
isLoading={areaLoading}
|
||||||
error={dailyError}
|
error={areaError}
|
||||||
isEmpty={isDailyEmpty}
|
isEmpty={isAreaEmpty}
|
||||||
onRetry={() => refetchDaily()}
|
onRetry={() =>
|
||||||
emptyTitle={t('usage.emptyStateTitle')}
|
range === '24h' ? refetchHourly() : refetchDaily()
|
||||||
emptyDescription={t('usage.emptyStateDescription')}
|
}
|
||||||
errorMessage={t('usage.loadFailed')}
|
emptyTitle="No usage data"
|
||||||
retryLabel={t('usage.retry')}
|
emptyDescription="Data will appear here once proxy requests are recorded."
|
||||||
>
|
errorMessage="Failed to load usage data"
|
||||||
<div className="h-[280px]">
|
retryLabel="Retry"
|
||||||
<ResponsiveBar
|
>
|
||||||
data={chartData}
|
<div className="h-[320px]">
|
||||||
keys={['Prompt', 'Completion']}
|
<ResponsiveLine
|
||||||
indexBy="name"
|
data={areaChartData}
|
||||||
groupMode="grouped"
|
theme={nivoTheme}
|
||||||
theme={nivoTheme}
|
colors={[COLOR_PROMPT, COLOR_COMPLETION]}
|
||||||
colors={barColor}
|
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
||||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
xScale={{ type: 'point' }}
|
||||||
padding={0.2}
|
yScale={{ type: 'linear', min: 0, stacked: false }}
|
||||||
innerPadding={2}
|
curve="monotoneX"
|
||||||
borderRadius={2}
|
enableArea={true}
|
||||||
enableGridY={true}
|
areaOpacity={0.08}
|
||||||
enableGridX={false}
|
areaBlendMode="normal"
|
||||||
gridYValues={5}
|
enablePoints={true}
|
||||||
axisBottom={{
|
pointSize={4}
|
||||||
tickSize: 0,
|
pointColor={{ theme: 'background' }}
|
||||||
tickPadding: 5,
|
pointBorderWidth={2}
|
||||||
tickRotation: 0,
|
pointBorderColor={{ from: 'serieColor' }}
|
||||||
}}
|
enableGridX={false}
|
||||||
axisLeft={{
|
enableGridY={true}
|
||||||
tickSize: 0,
|
gridYValues={5}
|
||||||
tickPadding: 5,
|
axisBottom={{
|
||||||
tickRotation: 0,
|
tickSize: 0,
|
||||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
tickPadding: 8,
|
||||||
}}
|
tickRotation: 0,
|
||||||
tooltip={BarTooltip}
|
}}
|
||||||
legends={[
|
axisLeft={{
|
||||||
{
|
tickSize: 0,
|
||||||
dataFrom: 'keys',
|
tickPadding: 8,
|
||||||
anchor: 'bottom',
|
tickRotation: 0,
|
||||||
direction: 'row',
|
format: (v: number) =>
|
||||||
justify: false,
|
v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`,
|
||||||
translateX: 0,
|
}}
|
||||||
translateY: 30,
|
tooltip={LineTooltip}
|
||||||
itemsSpacing: 16,
|
legends={[
|
||||||
itemWidth: 80,
|
{
|
||||||
itemHeight: 16,
|
anchor: 'bottom',
|
||||||
itemDirection: 'left-to-right',
|
direction: 'row',
|
||||||
symbolSize: 8,
|
justify: false,
|
||||||
symbolShape: 'circle',
|
translateX: 0,
|
||||||
},
|
translateY: 36,
|
||||||
]}
|
itemsSpacing: 16,
|
||||||
animate={true}
|
itemWidth: 80,
|
||||||
/>
|
itemHeight: 16,
|
||||||
</div>
|
itemDirection: 'left-to-right',
|
||||||
</ChartCard>
|
symbolSize: 8,
|
||||||
|
symbolShape: 'circle',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
animate={true}
|
||||||
|
motionConfig="gentle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Model Usage Donut Chart */}
|
<div className="xl:col-span-2">
|
||||||
<ChartCard
|
<ChartCard
|
||||||
title={t('usage.byModel')}
|
title="Model Distribution"
|
||||||
isLoading={modelLoading}
|
isLoading={modelLoading}
|
||||||
error={modelError}
|
error={modelError}
|
||||||
isEmpty={isModelEmpty}
|
isEmpty={isModelEmpty}
|
||||||
onRetry={() => refetchModel()}
|
onRetry={() => refetchModel()}
|
||||||
emptyTitle={t('usage.emptyStateTitle')}
|
emptyTitle="No model data"
|
||||||
emptyDescription={t('usage.emptyStateDescription')}
|
emptyDescription="Model distribution will appear after proxy usage."
|
||||||
errorMessage={t('usage.loadFailed')}
|
errorMessage="Failed to load model data"
|
||||||
retryLabel={t('usage.retry')}
|
retryLabel="Retry"
|
||||||
>
|
>
|
||||||
<div className="h-[280px]">
|
<div className="h-[320px]">
|
||||||
<ResponsivePie
|
<ResponsiveBar
|
||||||
data={pieData}
|
data={modelBarData}
|
||||||
innerRadius={0.55}
|
keys={['tokens']}
|
||||||
padAngle={2}
|
indexBy="model"
|
||||||
colors={PIE_COLORS}
|
layout="horizontal"
|
||||||
borderWidth={0}
|
theme={nivoTheme}
|
||||||
theme={nivoTheme}
|
colors={PIE_COLORS}
|
||||||
margin={{ top: 10, right: 10, bottom: 40, left: 10 }}
|
colorBy="indexValue"
|
||||||
tooltip={PieTooltip}
|
margin={{ top: 0, right: 10, bottom: 10, left: 120 }}
|
||||||
legends={[
|
padding={0.3}
|
||||||
{
|
borderRadius={4}
|
||||||
anchor: 'bottom',
|
enableGridX={true}
|
||||||
direction: 'row',
|
enableGridY={false}
|
||||||
justify: false,
|
gridXValues={4}
|
||||||
translateX: 0,
|
axisBottom={null}
|
||||||
translateY: 30,
|
axisLeft={{ tickSize: 0, tickPadding: 8 }}
|
||||||
itemsSpacing: 16,
|
axisTop={null}
|
||||||
itemWidth: 80,
|
axisRight={null}
|
||||||
itemHeight: 16,
|
enableLabel={true}
|
||||||
itemDirection: 'left-to-right',
|
label={(d) => formatNumber(d.value as number)}
|
||||||
symbolSize: 8,
|
labelSkipWidth={40}
|
||||||
symbolShape: 'circle',
|
labelTextColor="#e8eaed"
|
||||||
},
|
tooltip={({ indexValue, value }) => (
|
||||||
]}
|
<div className="bg-popover rounded-lg border border-white/[0.06] px-3 py-2 shadow-md">
|
||||||
animate={true}
|
<p className="text-foreground mb-1 text-xs font-medium">
|
||||||
/>
|
{indexValue}
|
||||||
</div>
|
</p>
|
||||||
</ChartCard>
|
<span className="text-foreground font-mono text-xs font-medium">
|
||||||
|
{(value as number).toLocaleString()} tokens
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
animate={true}
|
||||||
|
motionConfig="gentle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ChartCard>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Hourly Usage — 24h only */}
|
|
||||||
{range === '24h' && (
|
|
||||||
<ChartCard
|
|
||||||
title={t('usage.hourlyUsage')}
|
|
||||||
isLoading={hourlyLoading}
|
|
||||||
error={hourlyError}
|
|
||||||
isEmpty={isHourlyEmpty}
|
|
||||||
onRetry={() => refetchHourly()}
|
|
||||||
emptyTitle={t('usage.emptyStateTitle')}
|
|
||||||
emptyDescription={t('usage.emptyStateDescription')}
|
|
||||||
errorMessage={t('usage.loadFailed')}
|
|
||||||
retryLabel={t('usage.retry')}
|
|
||||||
>
|
|
||||||
<div className="h-[280px]">
|
|
||||||
<ResponsiveBar
|
|
||||||
data={hourlyChartData}
|
|
||||||
keys={['Prompt', 'Completion', 'Total']}
|
|
||||||
indexBy="name"
|
|
||||||
groupMode="grouped"
|
|
||||||
theme={nivoTheme}
|
|
||||||
colors={barColor}
|
|
||||||
margin={{ top: 10, right: 10, bottom: 40, left: 50 }}
|
|
||||||
padding={0.2}
|
|
||||||
innerPadding={2}
|
|
||||||
borderRadius={2}
|
|
||||||
enableGridY={true}
|
|
||||||
enableGridX={false}
|
|
||||||
gridYValues={5}
|
|
||||||
axisBottom={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 5,
|
|
||||||
tickRotation: 0,
|
|
||||||
tickValues: hourlyTickValues,
|
|
||||||
}}
|
|
||||||
axisLeft={{
|
|
||||||
tickSize: 0,
|
|
||||||
tickPadding: 5,
|
|
||||||
tickRotation: 0,
|
|
||||||
format: (v: number) => (v >= 1000 ? `${(v / 1000).toFixed(1)}k` : `${v}`),
|
|
||||||
}}
|
|
||||||
tooltip={BarTooltip}
|
|
||||||
legends={[
|
|
||||||
{
|
|
||||||
dataFrom: 'keys',
|
|
||||||
anchor: 'bottom',
|
|
||||||
direction: 'row',
|
|
||||||
justify: false,
|
|
||||||
translateX: 0,
|
|
||||||
translateY: 30,
|
|
||||||
itemsSpacing: 16,
|
|
||||||
itemWidth: 80,
|
|
||||||
itemHeight: 16,
|
|
||||||
itemDirection: 'left-to-right',
|
|
||||||
symbolSize: 8,
|
|
||||||
symbolShape: 'circle',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
animate={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</ChartCard>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -77,12 +77,15 @@ export class ProxyService {
|
|||||||
if (!usage) {
|
if (!usage) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const prompt = usage.promptTokenCount ?? 0;
|
||||||
|
const completion = usage.candidatesTokenCount ?? 0;
|
||||||
|
const total = usage.totalTokenCount ?? (prompt + completion);
|
||||||
this.tokenUsageService.recordUsage({
|
this.tokenUsageService.recordUsage({
|
||||||
accountId,
|
accountId,
|
||||||
model,
|
model,
|
||||||
promptTokens: usage.promptTokenCount ?? 0,
|
promptTokens: prompt,
|
||||||
completionTokens: usage.candidatesTokenCount ?? 0,
|
completionTokens: completion,
|
||||||
totalTokens: usage.totalTokenCount ?? 0,
|
totalTokens: total,
|
||||||
timestamp: Date.now(),
|
timestamp: Date.now(),
|
||||||
requestType,
|
requestType,
|
||||||
isEstimated,
|
isEstimated,
|
||||||
@@ -319,14 +322,15 @@ export class ProxyService {
|
|||||||
if (startMsg) subscriber.next(startMsg);
|
if (startMsg) subscriber.next(startMsg);
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = json.candidates?.[0];
|
const responsePayload = json.response ?? json;
|
||||||
|
const candidate = responsePayload.candidates?.[0];
|
||||||
const part = candidate?.content?.parts?.[0];
|
const part = candidate?.content?.parts?.[0];
|
||||||
|
|
||||||
if (candidate?.finishReason) {
|
if (candidate?.finishReason) {
|
||||||
lastFinishReason = candidate.finishReason;
|
lastFinishReason = candidate.finishReason;
|
||||||
}
|
}
|
||||||
if (json.usageMetadata) {
|
if (responsePayload.usageMetadata) {
|
||||||
lastUsageMetadata = json.usageMetadata;
|
lastUsageMetadata = responsePayload.usageMetadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.isGeminiPart(part)) {
|
if (this.isGeminiPart(part)) {
|
||||||
@@ -605,10 +609,11 @@ export class ProxyService {
|
|||||||
if (dataStr === '[DONE]') continue;
|
if (dataStr === '[DONE]') continue;
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(dataStr);
|
const parsed = JSON.parse(dataStr);
|
||||||
if (parsed?.usageMetadata) {
|
const responsePayload = parsed?.response ?? parsed;
|
||||||
lastUsageMetadata = parsed.usageMetadata;
|
if (responsePayload?.usageMetadata) {
|
||||||
|
lastUsageMetadata = responsePayload.usageMetadata;
|
||||||
}
|
}
|
||||||
const text = parsed?.candidates?.[0]?.content?.parts?.[0]?.text;
|
const text = responsePayload?.candidates?.[0]?.content?.parts?.[0]?.text;
|
||||||
if (typeof text === 'string') {
|
if (typeof text === 'string') {
|
||||||
accumulatedText += text;
|
accumulatedText += text;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,87 +1,105 @@
|
|||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
@import 'tailwindcss';
|
@import 'tailwindcss';
|
||||||
|
|
||||||
@plugin 'tailwindcss-animate';
|
@plugin 'tailwindcss-animate';
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme {
|
/*
|
||||||
--color-border: hsl(var(--border));
|
* Gemini Nexus Design System
|
||||||
--color-input: hsl(var(--input));
|
* Style: Minimal Dark Professional
|
||||||
--color-ring: hsl(var(--ring));
|
* Fonts: Inter (UI) + JetBrains Mono (code)
|
||||||
--color-background: hsl(var(--background));
|
* Palette: Refined Slate + Emerald accents
|
||||||
--color-foreground: hsl(var(--foreground));
|
*/
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
--font-mono: 'JetBrains Mono', 'Fira Code', 'Cascadia Code', monospace;
|
||||||
|
|
||||||
--color-primary: hsl(var(--primary));
|
--radius: 0.625rem;
|
||||||
--color-primary-foreground: hsl(var(--primary-foreground));
|
|
||||||
|
|
||||||
--color-secondary: hsl(var(--secondary));
|
|
||||||
--color-secondary-foreground: hsl(var(--secondary-foreground));
|
|
||||||
|
|
||||||
--color-destructive: hsl(var(--destructive));
|
|
||||||
--color-destructive-foreground: hsl(var(--destructive-foreground));
|
|
||||||
|
|
||||||
--color-muted: hsl(var(--muted));
|
|
||||||
--color-muted-foreground: hsl(var(--muted-foreground));
|
|
||||||
|
|
||||||
--color-accent: hsl(var(--accent));
|
|
||||||
--color-accent-foreground: hsl(var(--accent-foreground));
|
|
||||||
|
|
||||||
--color-popover: hsl(var(--popover));
|
|
||||||
--color-popover-foreground: hsl(var(--popover-foreground));
|
|
||||||
|
|
||||||
--color-card: hsl(var(--card));
|
|
||||||
--color-card-foreground: hsl(var(--card-foreground));
|
|
||||||
|
|
||||||
--font-sans: Geist, 'Microsoft YaHei', 'Heiti SC', 'WenQuanYi Micro Hei', sans-serif;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--font-tomorrow: Tomorrow, sans-serif;
|
|
||||||
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--radius-2xl: calc(var(--radius) + 8px);
|
||||||
|
|
||||||
|
--color-background: var(--background);
|
||||||
|
--color-foreground: var(--foreground);
|
||||||
|
--color-card: var(--card);
|
||||||
|
--color-card-foreground: var(--card-foreground);
|
||||||
|
--color-popover: var(--popover);
|
||||||
|
--color-popover-foreground: var(--popover-foreground);
|
||||||
|
--color-primary: var(--primary);
|
||||||
|
--color-primary-foreground: var(--primary-foreground);
|
||||||
|
--color-secondary: var(--secondary);
|
||||||
|
--color-secondary-foreground: var(--secondary-foreground);
|
||||||
|
--color-muted: var(--muted);
|
||||||
|
--color-muted-foreground: var(--muted-foreground);
|
||||||
|
--color-accent: var(--accent);
|
||||||
|
--color-accent-foreground: var(--accent-foreground);
|
||||||
|
--color-destructive: var(--destructive);
|
||||||
|
--color-destructive-foreground: var(--destructive-foreground);
|
||||||
|
--color-border: var(--border);
|
||||||
|
--color-input: var(--input);
|
||||||
|
--color-ring: var(--ring);
|
||||||
|
|
||||||
|
--color-chart-1: var(--chart-1);
|
||||||
|
--color-chart-2: var(--chart-2);
|
||||||
|
--color-chart-3: var(--chart-3);
|
||||||
|
--color-chart-4: var(--chart-4);
|
||||||
|
--color-chart-5: var(--chart-5);
|
||||||
|
|
||||||
|
--color-sidebar: var(--sidebar);
|
||||||
|
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||||
|
--color-sidebar-primary: var(--sidebar-primary);
|
||||||
|
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||||
|
--color-sidebar-accent: var(--sidebar-accent);
|
||||||
|
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||||
|
--color-sidebar-border: var(--sidebar-border);
|
||||||
|
--color-sidebar-ring: var(--sidebar-ring);
|
||||||
|
|
||||||
--animate-accordion-down: accordion-down 0.2s ease-out;
|
--animate-accordion-down: accordion-down 0.2s ease-out;
|
||||||
--animate-accordion-up: accordion-up 0.2s ease-out;
|
--animate-accordion-up: accordion-up 0.2s ease-out;
|
||||||
|
--animate-fade-in: fade-in 0.2s ease-out;
|
||||||
|
--animate-slide-up: slide-up 0.25s ease-out;
|
||||||
|
--animate-scale-in: scale-in 0.2s ease-out;
|
||||||
|
|
||||||
@keyframes accordion-down {
|
@keyframes accordion-down {
|
||||||
from {
|
from { height: 0; }
|
||||||
height: 0;
|
to { height: var(--radix-accordion-content-height); }
|
||||||
}
|
|
||||||
to {
|
|
||||||
height: var(--radix-accordion-content-height);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@keyframes accordion-up {
|
@keyframes accordion-up {
|
||||||
from {
|
from { height: var(--radix-accordion-content-height); }
|
||||||
height: var(--radix-accordion-content-height);
|
to { height: 0; }
|
||||||
}
|
}
|
||||||
to {
|
@keyframes fade-in {
|
||||||
height: 0;
|
from { opacity: 0; }
|
||||||
}
|
to { opacity: 1; }
|
||||||
|
}
|
||||||
|
@keyframes slide-up {
|
||||||
|
from { opacity: 0; transform: translateY(8px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
@keyframes scale-in {
|
||||||
|
from { opacity: 0; transform: scale(0.97); }
|
||||||
|
to { opacity: 1; transform: scale(1); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Container utility */
|
||||||
@utility container {
|
@utility container {
|
||||||
margin-inline: auto;
|
margin-inline: auto;
|
||||||
padding-inline: 2rem;
|
padding-inline: 2rem;
|
||||||
@media (width >= --theme(--breakpoint-sm)) {
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
@media (width >= 1400px) {
|
@media (width >= 1400px) {
|
||||||
max-width: 1400px;
|
max-width: 1400px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Font faces — local fallbacks */
|
||||||
@layer base {
|
@layer base {
|
||||||
@font-face {
|
|
||||||
font-family: 'Geist';
|
|
||||||
|
|
||||||
src: url('../assets/fonts/geist/geist.ttf') format('truetype');
|
|
||||||
}
|
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Geist Mono';
|
font-family: 'Geist Mono';
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
|
|
||||||
src: url('../assets/fonts/geist-mono/geist-mono.ttf') format('truetype');
|
src: url('../assets/fonts/geist-mono/geist-mono.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,296 +107,157 @@
|
|||||||
font-family: 'Tomorrow';
|
font-family: 'Tomorrow';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
||||||
src: url('../assets/fonts/tomorrow/tomorrow-regular.ttf') format('truetype');
|
src: url('../assets/fonts/tomorrow/tomorrow-regular.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Tomorrow';
|
font-family: 'Tomorrow';
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
src: url('../assets/fonts/tomorrow/tomorrow-italic.ttf') format('truetype');
|
src: url('../assets/fonts/tomorrow/tomorrow-italic.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Tomorrow';
|
font-family: 'Tomorrow';
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
|
|
||||||
src: url('../assets/fonts/tomorrow/tomorrow-bold.ttf') format('truetype');
|
src: url('../assets/fonts/tomorrow/tomorrow-bold.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Tomorrow';
|
font-family: 'Tomorrow';
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
|
|
||||||
src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
|
src: url('../assets/fonts/tomorrow/tomorrow-bold-italic.ttf') format('truetype');
|
||||||
}
|
}
|
||||||
body {
|
|
||||||
letter-spacing: var(--tracking-normal);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Refined dark color palette
|
||||||
|
* Inspired by: Linear, Vercel, Raycast
|
||||||
|
* Deep neutral darks with subtle warm undertones
|
||||||
|
*/
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root,
|
||||||
--background: 0 0% 100%;
|
|
||||||
--foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--card: 0 0% 100%;
|
|
||||||
--card-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--popover: 0 0% 100%;
|
|
||||||
--popover-foreground: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--primary: 222.2 47.4% 11.2%;
|
|
||||||
--primary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--secondary: 210 40% 96.1%;
|
|
||||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--muted: 210 40% 96.1%;
|
|
||||||
--muted-foreground: 215.4 16.3% 46.9%;
|
|
||||||
|
|
||||||
--accent: 210 40% 96.1%;
|
|
||||||
--accent-foreground: 222.2 47.4% 11.2%;
|
|
||||||
|
|
||||||
--destructive: 0 84.2% 60.2%;
|
|
||||||
--destructive-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--border: 214.3 31.8% 91.4%;
|
|
||||||
--input: 214.3 31.8% 91.4%;
|
|
||||||
--ring: 222.2 84% 4.9%;
|
|
||||||
|
|
||||||
--radius: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
/* Core surfaces */
|
||||||
--foreground: 210 40% 98%;
|
--background: #0a0a0f;
|
||||||
|
--foreground: #e8eaed;
|
||||||
|
--card: #111118;
|
||||||
|
--card-foreground: #e8eaed;
|
||||||
|
--popover: #16161e;
|
||||||
|
--popover-foreground: #e8eaed;
|
||||||
|
|
||||||
--card: 222.2 84% 4.9%;
|
/* Interactive elements */
|
||||||
--card-foreground: 210 40% 98%;
|
--primary: #e8eaed;
|
||||||
|
--primary-foreground: #0a0a0f;
|
||||||
|
--secondary: #1c1c26;
|
||||||
|
--secondary-foreground: #c4c7cc;
|
||||||
|
--muted: #16161e;
|
||||||
|
--muted-foreground: #6b7280;
|
||||||
|
--accent: #10b981;
|
||||||
|
--accent-foreground: #ffffff;
|
||||||
|
|
||||||
--popover: 222.2 84% 4.9%;
|
/* State colors */
|
||||||
--popover-foreground: 210 40% 98%;
|
--destructive: #ef4444;
|
||||||
|
--destructive-foreground: #ffffff;
|
||||||
|
|
||||||
--primary: 210 40% 98%;
|
/* Boundaries */
|
||||||
--primary-foreground: 222.2 47.4% 11.2%;
|
--border: #ffffff0d;
|
||||||
|
--input: #1c1c26;
|
||||||
|
--ring: #10b981;
|
||||||
|
|
||||||
--secondary: 217.2 32.6% 17.5%;
|
--radius: 0.625rem;
|
||||||
--secondary-foreground: 210 40% 98%;
|
|
||||||
|
|
||||||
--muted: 217.2 32.6% 17.5%;
|
/* Chart palette — vibrant on dark */
|
||||||
--muted-foreground: 215 20.2% 65.1%;
|
--chart-1: #6366f1;
|
||||||
|
--chart-2: #10b981;
|
||||||
|
--chart-3: #f59e0b;
|
||||||
|
--chart-4: #ec4899;
|
||||||
|
--chart-5: #8b5cf6;
|
||||||
|
|
||||||
--accent: 217.2 32.6% 17.5%;
|
/* Sidebar */
|
||||||
--accent-foreground: 210 40% 98%;
|
--sidebar: #0a0a0f;
|
||||||
|
--sidebar-foreground: #e8eaed;
|
||||||
--destructive: 0 62.8% 30.6%;
|
--sidebar-primary: #e8eaed;
|
||||||
--destructive-foreground: 210 40% 98%;
|
--sidebar-primary-foreground: #0a0a0f;
|
||||||
|
--sidebar-accent: #10b981;
|
||||||
--border: 217.2 32.6% 17.5%;
|
--sidebar-accent-foreground: #ffffff;
|
||||||
--input: 217.2 32.6% 17.5%;
|
--sidebar-border: #ffffff0d;
|
||||||
--ring: 212.7 26.8% 83.9%;
|
--sidebar-ring: #10b981;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Global base styles */
|
||||||
@layer base {
|
@layer base {
|
||||||
body {
|
* {
|
||||||
@apply overflow-hidden;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-background text-foreground overflow-hidden antialiased;
|
||||||
|
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
.draglayer {
|
.draglayer {
|
||||||
@apply bg-background;
|
@apply bg-background;
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@layer base {
|
/* Smooth scrollbar for dark mode */
|
||||||
* {
|
::-webkit-scrollbar {
|
||||||
@apply border-border outline-ring/50;
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
}
|
}
|
||||||
body {
|
::-webkit-scrollbar-track {
|
||||||
@apply bg-background text-foreground;
|
background: transparent;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #ffffff1a;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #ffffff33;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection color */
|
||||||
|
::selection {
|
||||||
|
background: #10b98133;
|
||||||
|
color: #e8eaed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
/* Utility classes for the design system */
|
||||||
--font-sans: Geist, sans-serif;
|
@layer utilities {
|
||||||
--font-mono: Geist Mono, monospace;
|
.glass {
|
||||||
--font-serif: Georgia, serif;
|
background: rgba(17, 17, 24, 0.6);
|
||||||
--radius: 0.5rem;
|
backdrop-filter: blur(12px);
|
||||||
--tracking-tighter: calc(var(--tracking-normal) - 0.05em);
|
-webkit-backdrop-filter: blur(12px);
|
||||||
--tracking-tight: calc(var(--tracking-normal) - 0.025em);
|
}
|
||||||
--tracking-wide: calc(var(--tracking-normal) + 0.025em);
|
|
||||||
--tracking-wider: calc(var(--tracking-normal) + 0.05em);
|
|
||||||
--tracking-widest: calc(var(--tracking-normal) + 0.1em);
|
|
||||||
--tracking-normal: var(--tracking-normal);
|
|
||||||
--shadow-2xl: var(--shadow-2xl);
|
|
||||||
--shadow-xl: var(--shadow-xl);
|
|
||||||
--shadow-lg: var(--shadow-lg);
|
|
||||||
--shadow-md: var(--shadow-md);
|
|
||||||
--shadow: var(--shadow);
|
|
||||||
--shadow-sm: var(--shadow-sm);
|
|
||||||
--shadow-xs: var(--shadow-xs);
|
|
||||||
--shadow-2xs: var(--shadow-2xs);
|
|
||||||
--spacing: var(--spacing);
|
|
||||||
--letter-spacing: var(--letter-spacing);
|
|
||||||
--shadow-offset-y: var(--shadow-offset-y);
|
|
||||||
--shadow-offset-x: var(--shadow-offset-x);
|
|
||||||
--shadow-spread: var(--shadow-spread);
|
|
||||||
--shadow-blur: var(--shadow-blur);
|
|
||||||
--shadow-opacity: var(--shadow-opacity);
|
|
||||||
--color-shadow-color: var(--shadow-color);
|
|
||||||
--color-sidebar-ring: var(--sidebar-ring);
|
|
||||||
--color-sidebar-border: var(--sidebar-border);
|
|
||||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
|
||||||
--color-sidebar-accent: var(--sidebar-accent);
|
|
||||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
|
||||||
--color-sidebar-primary: var(--sidebar-primary);
|
|
||||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
|
||||||
--color-sidebar: var(--sidebar);
|
|
||||||
--color-chart-5: var(--chart-5);
|
|
||||||
--color-chart-4: var(--chart-4);
|
|
||||||
--color-chart-3: var(--chart-3);
|
|
||||||
--color-chart-2: var(--chart-2);
|
|
||||||
--color-chart-1: var(--chart-1);
|
|
||||||
--color-ring: var(--ring);
|
|
||||||
--color-input: var(--input);
|
|
||||||
--color-border: var(--border);
|
|
||||||
--color-destructive-foreground: var(--destructive-foreground);
|
|
||||||
--color-destructive: var(--destructive);
|
|
||||||
--color-accent-foreground: var(--accent-foreground);
|
|
||||||
--color-accent: var(--accent);
|
|
||||||
--color-muted-foreground: var(--muted-foreground);
|
|
||||||
--color-muted: var(--muted);
|
|
||||||
--color-secondary-foreground: var(--secondary-foreground);
|
|
||||||
--color-secondary: var(--secondary);
|
|
||||||
--color-primary-foreground: var(--primary-foreground);
|
|
||||||
--color-primary: var(--primary);
|
|
||||||
--color-popover-foreground: var(--popover-foreground);
|
|
||||||
--color-popover: var(--popover);
|
|
||||||
--color-card-foreground: var(--card-foreground);
|
|
||||||
--color-card: var(--card);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--color-background: var(--background);
|
|
||||||
--radius-sm: calc(var(--radius) - 4px);
|
|
||||||
--radius-md: calc(var(--radius) - 2px);
|
|
||||||
--radius-lg: var(--radius);
|
|
||||||
--radius-xl: calc(var(--radius) + 4px);
|
|
||||||
--radius-2xl: calc(var(--radius) + 8px);
|
|
||||||
--radius-3xl: calc(var(--radius) + 12px);
|
|
||||||
--radius-4xl: calc(var(--radius) + 16px);
|
|
||||||
}
|
|
||||||
|
|
||||||
:root {
|
.glass-border {
|
||||||
--background: oklch(0.99 0 0);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
--foreground: oklch(0 0 0);
|
}
|
||||||
--card: oklch(1 0 0);
|
|
||||||
--card-foreground: oklch(0 0 0);
|
|
||||||
--popover: oklch(0.99 0 0);
|
|
||||||
--popover-foreground: oklch(0 0 0);
|
|
||||||
--primary: oklch(0 0 0);
|
|
||||||
--primary-foreground: oklch(1 0 0);
|
|
||||||
--secondary: oklch(0.94 0 0);
|
|
||||||
--secondary-foreground: oklch(0 0 0);
|
|
||||||
--muted: oklch(0.97 0 0);
|
|
||||||
--muted-foreground: oklch(0.44 0 0);
|
|
||||||
--accent: oklch(0.94 0 0);
|
|
||||||
--accent-foreground: oklch(0 0 0);
|
|
||||||
--destructive: oklch(0.63 0.19 23.03);
|
|
||||||
--destructive-foreground: oklch(1 0 0);
|
|
||||||
--border: oklch(0.92 0 0);
|
|
||||||
--input: oklch(0.94 0 0);
|
|
||||||
--ring: oklch(0 0 0);
|
|
||||||
--chart-1: oklch(0.81 0.17 75.35);
|
|
||||||
--chart-2: oklch(0.55 0.22 264.53);
|
|
||||||
--chart-3: oklch(0.72 0 0);
|
|
||||||
--chart-4: oklch(0.92 0 0);
|
|
||||||
--chart-5: oklch(0.56 0 0);
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--sidebar: oklch(0.99 0 0);
|
|
||||||
--sidebar-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-primary: oklch(0 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(1 0 0);
|
|
||||||
--sidebar-accent: oklch(0.94 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-border: oklch(0.94 0 0);
|
|
||||||
--sidebar-ring: oklch(0 0 0);
|
|
||||||
--font-sans: Geist, sans-serif;
|
|
||||||
--font-serif: Georgia, serif;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--shadow-color: hsl(0 0% 0%);
|
|
||||||
--shadow-opacity: 0.18;
|
|
||||||
--shadow-blur: 2px;
|
|
||||||
--shadow-spread: 0px;
|
|
||||||
--shadow-offset-x: 0px;
|
|
||||||
--shadow-offset-y: 1px;
|
|
||||||
--letter-spacing: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
|
||||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
|
||||||
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
|
||||||
--tracking-normal: 0em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dark {
|
.glow-accent {
|
||||||
--background: oklch(0 0 0);
|
box-shadow: 0 0 20px rgba(16, 185, 129, 0.1);
|
||||||
--foreground: oklch(1 0 0);
|
}
|
||||||
--card: oklch(0.14 0 0);
|
|
||||||
--card-foreground: oklch(1 0 0);
|
.glow-accent-sm {
|
||||||
--popover: oklch(0.18 0 0);
|
box-shadow: 0 0 10px rgba(16, 185, 129, 0.08);
|
||||||
--popover-foreground: oklch(1 0 0);
|
}
|
||||||
--primary: oklch(1 0 0);
|
|
||||||
--primary-foreground: oklch(0 0 0);
|
.text-gradient {
|
||||||
--secondary: oklch(0.25 0 0);
|
background: linear-gradient(135deg, #e8eaed 0%, #6b7280 100%);
|
||||||
--secondary-foreground: oklch(1 0 0);
|
-webkit-background-clip: text;
|
||||||
--muted: oklch(0.23 0 0);
|
-webkit-text-fill-color: transparent;
|
||||||
--muted-foreground: oklch(0.72 0 0);
|
background-clip: text;
|
||||||
--accent: oklch(0.32 0 0);
|
}
|
||||||
--accent-foreground: oklch(1 0 0);
|
|
||||||
--destructive: oklch(0.69 0.2 23.91);
|
.animate-in {
|
||||||
--destructive-foreground: oklch(0 0 0);
|
animation: fade-in 0.2s ease-out, slide-up 0.25s ease-out;
|
||||||
--border: oklch(0.26 0 0);
|
}
|
||||||
--input: oklch(0.32 0 0);
|
|
||||||
--ring: oklch(0.72 0 0);
|
|
||||||
--chart-1: oklch(0.81 0.17 75.35);
|
|
||||||
--chart-2: oklch(0.58 0.21 260.84);
|
|
||||||
--chart-3: oklch(0.56 0 0);
|
|
||||||
--chart-4: oklch(0.44 0 0);
|
|
||||||
--chart-5: oklch(0.92 0 0);
|
|
||||||
--radius: 0.5rem;
|
|
||||||
--sidebar: oklch(0.18 0 0);
|
|
||||||
--sidebar-foreground: oklch(1 0 0);
|
|
||||||
--sidebar-primary: oklch(1 0 0);
|
|
||||||
--sidebar-primary-foreground: oklch(0 0 0);
|
|
||||||
--sidebar-accent: oklch(0.32 0 0);
|
|
||||||
--sidebar-accent-foreground: oklch(1 0 0);
|
|
||||||
--sidebar-border: oklch(0.32 0 0);
|
|
||||||
--sidebar-ring: oklch(0.72 0 0);
|
|
||||||
--font-sans: Geist, sans-serif;
|
|
||||||
--font-serif: Georgia, serif;
|
|
||||||
--font-mono: Geist Mono, monospace;
|
|
||||||
--shadow-color: hsl(0 0% 0%);
|
|
||||||
--shadow-opacity: 0.18;
|
|
||||||
--shadow-blur: 2px;
|
|
||||||
--shadow-spread: 0px;
|
|
||||||
--shadow-offset-x: 0px;
|
|
||||||
--shadow-offset-y: 1px;
|
|
||||||
--letter-spacing: 0em;
|
|
||||||
--spacing: 0.25rem;
|
|
||||||
--shadow-2xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
|
||||||
--shadow-xs: 0px 1px 2px 0px hsl(0 0% 0% / 0.09);
|
|
||||||
--shadow-sm: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 1px 2px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-md: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 2px 4px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-lg: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 4px 6px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.18), 0px 8px 10px -1px hsl(0 0% 0% / 0.18);
|
|
||||||
--shadow-2xl: 0px 1px 2px 0px hsl(0 0% 0% / 0.45);
|
|
||||||
}
|
}
|
||||||
@@ -87,50 +87,50 @@ export type TokenUsageRow = z.infer<typeof TokenUsageRowSchema>;
|
|||||||
|
|
||||||
export const HourlyUsageSchema = z.object({
|
export const HourlyUsageSchema = z.object({
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
promptTokens: z.number(),
|
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
completionTokens: z.number(),
|
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
totalTokens: z.number(),
|
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
requests: z.number(),
|
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type HourlyUsage = z.infer<typeof HourlyUsageSchema>;
|
export type HourlyUsage = z.infer<typeof HourlyUsageSchema>;
|
||||||
|
|
||||||
export const DailyUsageSchema = z.object({
|
export const DailyUsageSchema = z.object({
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
promptTokens: z.number(),
|
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
completionTokens: z.number(),
|
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
totalTokens: z.number(),
|
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
requests: z.number(),
|
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type DailyUsage = z.infer<typeof DailyUsageSchema>;
|
export type DailyUsage = z.infer<typeof DailyUsageSchema>;
|
||||||
|
|
||||||
export const WeeklyUsageSchema = z.object({
|
export const WeeklyUsageSchema = z.object({
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
promptTokens: z.number(),
|
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
completionTokens: z.number(),
|
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
totalTokens: z.number(),
|
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
requests: z.number(),
|
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type WeeklyUsage = z.infer<typeof WeeklyUsageSchema>;
|
export type WeeklyUsage = z.infer<typeof WeeklyUsageSchema>;
|
||||||
|
|
||||||
export const MonthlyUsageSchema = z.object({
|
export const MonthlyUsageSchema = z.object({
|
||||||
bucket: z.string(),
|
bucket: z.string(),
|
||||||
promptTokens: z.number(),
|
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
completionTokens: z.number(),
|
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
totalTokens: z.number(),
|
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
requests: z.number(),
|
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type MonthlyUsage = z.infer<typeof MonthlyUsageSchema>;
|
export type MonthlyUsage = z.infer<typeof MonthlyUsageSchema>;
|
||||||
|
|
||||||
export const ModelUsageSchema = z.object({
|
export const ModelUsageSchema = z.object({
|
||||||
model: z.string(),
|
model: z.string(),
|
||||||
promptTokens: z.number(),
|
promptTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
completionTokens: z.number(),
|
completionTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
totalTokens: z.number(),
|
totalTokens: z.number().nullable().transform((v) => v ?? 0),
|
||||||
requests: z.number(),
|
requests: z.number().nullable().transform((v) => v ?? 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ModelUsage = z.infer<typeof ModelUsageSchema>;
|
export type ModelUsage = z.infer<typeof ModelUsageSchema>;
|
||||||