mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 14:16:43 +00:00
feat(cliproxy): add provider editor with presets and control panel
- Add split-view provider editor with model mapping UI and JSON editor - Implement persistent custom presets (save/load per provider) - Add provider logos with white backgrounds for light/dark theme - Integrate CLIProxyAPI control panel embed via iframe - Add service manager for CLIProxyAPI lifecycle control - Support variant logo display using parent provider - Add categorized model selector with search and groups
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Verify UI bundle size is under 1MB gzipped
|
||||
* React + shadcn/ui dashboard typically ranges 600-900KB
|
||||
* Verify UI bundle size stays reasonable
|
||||
*
|
||||
* Current stack: React + TanStack Query + Radix UI + shadcn/ui + Recharts
|
||||
* Expected range: 800KB - 1.5MB gzipped for full-featured dashboard
|
||||
*
|
||||
* This is a developer tool, not a public-facing site, so we optimize for
|
||||
* features over minimal bundle size. The limit is a sanity check to catch
|
||||
* accidental large dependencies, not a hard performance target.
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
@@ -10,7 +16,7 @@ const path = require('path');
|
||||
const zlib = require('zlib');
|
||||
|
||||
const UI_DIR = path.join(__dirname, '../dist/ui');
|
||||
const MAX_SIZE = 1024 * 1024; // 1MB
|
||||
const MAX_SIZE = 1.5 * 1024 * 1024; // 1.5MB - reasonable for full-featured React dashboard
|
||||
|
||||
function getGzipSize(filePath) {
|
||||
const content = fs.readFileSync(filePath);
|
||||
@@ -40,9 +46,10 @@ if (!fs.existsSync(UI_DIR)) {
|
||||
|
||||
const totalSize = walkDir(UI_DIR);
|
||||
const sizeKB = (totalSize / 1024).toFixed(1);
|
||||
const maxKB = (MAX_SIZE / 1024).toFixed(0);
|
||||
|
||||
if (totalSize > MAX_SIZE) {
|
||||
console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: 1024KB)`);
|
||||
console.log(`[X] Bundle too large: ${sizeKB}KB gzipped (max: ${maxKB}KB)`);
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.log(`[OK] Bundle size: ${sizeKB}KB gzipped`);
|
||||
|
||||
@@ -25,10 +25,13 @@ export const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
/** Internal API key for CCS-managed requests */
|
||||
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
||||
|
||||
/** Simple secret key for Control Panel login (user-facing) */
|
||||
export const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
|
||||
/**
|
||||
* Config version - bump when config format changes to trigger regeneration
|
||||
* v1: Initial config (port, auth-dir, api-keys only)
|
||||
* v2: Enhanced config (quota-exceeded, request-retry, usage-statistics)
|
||||
* v2: Full-featured config with dashboard, quota mgmt, simplified key
|
||||
*/
|
||||
export const CLIPROXY_CONFIG_VERSION = 2;
|
||||
|
||||
@@ -123,36 +126,64 @@ function generateUnifiedConfigContent(port: number = CLIPROXY_DEFAULT_PORT): str
|
||||
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
||||
# Generated: ${new Date().toISOString()}
|
||||
# DO NOT EDIT - regenerated by CCS. Use 'ccs doctor' to update.
|
||||
#
|
||||
# This config is auto-managed by CCS. Manual edits may be overwritten.
|
||||
# Use 'ccs doctor' to regenerate with latest settings.
|
||||
|
||||
# =============================================================================
|
||||
# Server Settings
|
||||
# =============================================================================
|
||||
|
||||
port: ${port}
|
||||
debug: false
|
||||
logging-to-file: false
|
||||
|
||||
# Usage statistics for dashboard analytics
|
||||
# =============================================================================
|
||||
# Logging
|
||||
# =============================================================================
|
||||
|
||||
# Write logs to file (stored in ~/.ccs/cliproxy/logs/)
|
||||
logging-to-file: true
|
||||
|
||||
# Log individual API requests for debugging/analytics
|
||||
request-log: true
|
||||
|
||||
# =============================================================================
|
||||
# Dashboard & Management
|
||||
# =============================================================================
|
||||
|
||||
# Enable usage statistics for CCS dashboard analytics
|
||||
usage-statistics-enabled: true
|
||||
|
||||
# Management API for CCS dashboard (stats, control panel)
|
||||
# Remote management API for CCS dashboard integration
|
||||
remote-management:
|
||||
allow-remote: false
|
||||
secret-key: "${CCS_INTERNAL_API_KEY}"
|
||||
disable-control-panel: true
|
||||
allow-remote: true
|
||||
secret-key: "${CCS_CONTROL_PANEL_SECRET}"
|
||||
disable-control-panel: false
|
||||
|
||||
# =============================================================================
|
||||
# Reliability & Quota Management
|
||||
# =============================================================================
|
||||
|
||||
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||
request-retry: 3
|
||||
max-retry-interval: 30
|
||||
request-retry: 0
|
||||
max-retry-interval: 0
|
||||
|
||||
# Auto-switch accounts on quota exceeded (429) - killer feature for multi-account
|
||||
# Auto-switch accounts on quota exceeded (429)
|
||||
# This enables seamless multi-account rotation when rate limited
|
||||
quota-exceeded:
|
||||
switch-project: true
|
||||
switch-preview-model: true
|
||||
|
||||
# CCS internal authentication
|
||||
# =============================================================================
|
||||
# Authentication
|
||||
# =============================================================================
|
||||
|
||||
# API keys for CCS internal requests
|
||||
api-keys:
|
||||
- "${CCS_INTERNAL_API_KEY}"
|
||||
|
||||
# OAuth tokens stored in auth/ directory
|
||||
auth-dir: "${authDir.replace(/\\/g, '/')}"
|
||||
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
||||
auth-dir: "${authDir.replace(/\\\\/g, '/')}"
|
||||
`;
|
||||
|
||||
return config;
|
||||
@@ -219,7 +250,7 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config needs regeneration (version mismatch or missing required fields)
|
||||
* Check if config needs regeneration (version mismatch)
|
||||
* @returns true if config should be regenerated
|
||||
*/
|
||||
export function configNeedsRegeneration(): boolean {
|
||||
@@ -238,20 +269,7 @@ export function configNeedsRegeneration(): boolean {
|
||||
}
|
||||
|
||||
const configVersion = parseInt(versionMatch[1], 10);
|
||||
if (configVersion < CLIPROXY_CONFIG_VERSION) {
|
||||
return true; // Outdated version
|
||||
}
|
||||
|
||||
// Check for required fields (v2 features)
|
||||
const hasQuotaExceeded = content.includes('quota-exceeded:');
|
||||
const hasRequestRetry = content.includes('request-retry:');
|
||||
const hasUsageStats = content.includes('usage-statistics-enabled: true');
|
||||
|
||||
if (!hasQuotaExceeded || !hasRequestRetry || !hasUsageStats) {
|
||||
return true; // Missing required fields
|
||||
}
|
||||
|
||||
return false;
|
||||
return configVersion < CLIPROXY_CONFIG_VERSION;
|
||||
} catch {
|
||||
return true; // Error reading = regenerate
|
||||
}
|
||||
|
||||
@@ -117,3 +117,7 @@ export {
|
||||
OPENROUTER_TEMPLATE,
|
||||
TOGETHER_TEMPLATE,
|
||||
} from './openai-compat-manager';
|
||||
|
||||
// Service manager (background CLIProxy for dashboard)
|
||||
export type { ServiceStartResult } from './service-manager';
|
||||
export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager';
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* CLIProxy Service Manager
|
||||
*
|
||||
* Manages CLIProxyAPI as a background service for the CCS dashboard.
|
||||
* Ensures the proxy is running when needed for:
|
||||
* - Control Panel integration (management.html)
|
||||
* - Stats fetching
|
||||
* - OAuth flows
|
||||
*
|
||||
* Unlike cliproxy-executor.ts which runs proxy per-session,
|
||||
* this module manages a persistent background instance.
|
||||
*/
|
||||
|
||||
import { spawn, ChildProcess } from 'child_process';
|
||||
import * as net from 'net';
|
||||
import { ensureCLIProxyBinary } from './binary-manager';
|
||||
import {
|
||||
generateConfig,
|
||||
regenerateConfig,
|
||||
configNeedsRegeneration,
|
||||
CLIPROXY_DEFAULT_PORT,
|
||||
} from './config-generator';
|
||||
import { isCliproxyRunning } from './stats-fetcher';
|
||||
|
||||
/** Background proxy process reference */
|
||||
let proxyProcess: ChildProcess | null = null;
|
||||
|
||||
/** Cleanup registered flag */
|
||||
let cleanupRegistered = false;
|
||||
|
||||
/**
|
||||
* Wait for TCP port to become available
|
||||
*/
|
||||
async function waitForPort(
|
||||
port: number,
|
||||
timeout: number = 5000,
|
||||
pollInterval: number = 100
|
||||
): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const socket = net.createConnection({ port, host: '127.0.0.1' }, () => {
|
||||
socket.destroy();
|
||||
resolve();
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
socket.destroy();
|
||||
reject(err);
|
||||
});
|
||||
|
||||
socket.setTimeout(500, () => {
|
||||
socket.destroy();
|
||||
reject(new Error('Connection timeout'));
|
||||
});
|
||||
});
|
||||
|
||||
return true; // Connection successful
|
||||
} catch {
|
||||
await new Promise((r) => setTimeout(r, pollInterval));
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register cleanup handlers to stop proxy on process exit
|
||||
*/
|
||||
function registerCleanup(): void {
|
||||
if (cleanupRegistered) return;
|
||||
|
||||
const cleanup = () => {
|
||||
if (proxyProcess && !proxyProcess.killed) {
|
||||
proxyProcess.kill('SIGTERM');
|
||||
proxyProcess = null;
|
||||
}
|
||||
};
|
||||
|
||||
process.once('exit', cleanup);
|
||||
process.once('SIGTERM', cleanup);
|
||||
process.once('SIGINT', cleanup);
|
||||
|
||||
cleanupRegistered = true;
|
||||
}
|
||||
|
||||
export interface ServiceStartResult {
|
||||
started: boolean;
|
||||
alreadyRunning: boolean;
|
||||
port: number;
|
||||
configRegenerated?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure CLIProxy service is running
|
||||
*
|
||||
* If proxy is already running, returns immediately.
|
||||
* If not, spawns a new background instance.
|
||||
*
|
||||
* @param port CLIProxy port (default: 8317)
|
||||
* @param verbose Show debug output
|
||||
* @returns Result indicating success and whether it was already running
|
||||
*/
|
||||
export async function ensureCliproxyService(
|
||||
port: number = CLIPROXY_DEFAULT_PORT,
|
||||
verbose: boolean = false
|
||||
): Promise<ServiceStartResult> {
|
||||
const log = (msg: string) => {
|
||||
if (verbose) {
|
||||
console.error(`[cliproxy-service] ${msg}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already running (from another process or previous start)
|
||||
log(`Checking if CLIProxy is running on port ${port}...`);
|
||||
const running = await isCliproxyRunning(port);
|
||||
|
||||
// Check if config needs update (even if running)
|
||||
let configRegenerated = false;
|
||||
if (configNeedsRegeneration()) {
|
||||
log('Config outdated, regenerating...');
|
||||
regenerateConfig(port);
|
||||
configRegenerated = true;
|
||||
}
|
||||
|
||||
if (running) {
|
||||
log('CLIProxy already running');
|
||||
if (configRegenerated) {
|
||||
log('Config was updated - running instance will use new config on next restart');
|
||||
}
|
||||
return { started: true, alreadyRunning: true, port, configRegenerated };
|
||||
}
|
||||
|
||||
// Need to start new instance
|
||||
log('CLIProxy not running, starting background instance...');
|
||||
|
||||
// 1. Ensure binary exists
|
||||
let binaryPath: string;
|
||||
try {
|
||||
binaryPath = await ensureCLIProxyBinary(verbose);
|
||||
log(`Binary ready: ${binaryPath}`);
|
||||
} catch (error) {
|
||||
const err = error as Error;
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port,
|
||||
error: `Failed to prepare binary: ${err.message}`,
|
||||
};
|
||||
}
|
||||
|
||||
// 2. Ensure/regenerate config if needed
|
||||
let configPath: string;
|
||||
if (configNeedsRegeneration()) {
|
||||
log('Config needs regeneration, updating...');
|
||||
configPath = regenerateConfig(port);
|
||||
} else {
|
||||
// generateConfig only creates if doesn't exist
|
||||
configPath = generateConfig('gemini', port); // Provider doesn't matter for unified config
|
||||
}
|
||||
log(`Config ready: ${configPath}`);
|
||||
|
||||
// 3. Spawn background process
|
||||
const proxyArgs = ['--config', configPath];
|
||||
|
||||
log(`Spawning: ${binaryPath} ${proxyArgs.join(' ')}`);
|
||||
|
||||
proxyProcess = spawn(binaryPath, proxyArgs, {
|
||||
stdio: ['ignore', verbose ? 'pipe' : 'ignore', verbose ? 'pipe' : 'ignore'],
|
||||
detached: true, // Allow process to run independently
|
||||
});
|
||||
|
||||
// Forward output in verbose mode
|
||||
if (verbose) {
|
||||
proxyProcess.stdout?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy] ${data.toString()}`);
|
||||
});
|
||||
proxyProcess.stderr?.on('data', (data: Buffer) => {
|
||||
process.stderr.write(`[cliproxy-err] ${data.toString()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Don't let this process prevent parent from exiting
|
||||
proxyProcess.unref();
|
||||
|
||||
// Handle spawn errors
|
||||
proxyProcess.on('error', (error) => {
|
||||
log(`Spawn error: ${error.message}`);
|
||||
});
|
||||
|
||||
// Register cleanup handlers
|
||||
registerCleanup();
|
||||
|
||||
// 4. Wait for proxy to be ready
|
||||
log(`Waiting for CLIProxy on port ${port}...`);
|
||||
const ready = await waitForPort(port, 5000);
|
||||
|
||||
if (!ready) {
|
||||
// Kill failed process
|
||||
if (proxyProcess && !proxyProcess.killed) {
|
||||
proxyProcess.kill('SIGTERM');
|
||||
proxyProcess = null;
|
||||
}
|
||||
|
||||
return {
|
||||
started: false,
|
||||
alreadyRunning: false,
|
||||
port,
|
||||
error: `CLIProxy failed to start within 5s on port ${port}`,
|
||||
};
|
||||
}
|
||||
|
||||
log(`CLIProxy service started on port ${port}`);
|
||||
return { started: true, alreadyRunning: false, port };
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the managed CLIProxy service
|
||||
*/
|
||||
export function stopCliproxyService(): boolean {
|
||||
if (proxyProcess && !proxyProcess.killed) {
|
||||
proxyProcess.kill('SIGTERM');
|
||||
proxyProcess = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get service status
|
||||
*/
|
||||
export async function getServiceStatus(port: number = CLIPROXY_DEFAULT_PORT): Promise<{
|
||||
running: boolean;
|
||||
managedByUs: boolean;
|
||||
port: number;
|
||||
}> {
|
||||
const running = await isCliproxyRunning(port);
|
||||
const managedByUs = proxyProcess !== null && !proxyProcess.killed;
|
||||
|
||||
return { running, managedByUs, port };
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Requires usage-statistics-enabled: true in config.yaml.
|
||||
*/
|
||||
|
||||
import { CCS_INTERNAL_API_KEY, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
import { CCS_CONTROL_PANEL_SECRET, CLIPROXY_DEFAULT_PORT } from './config-generator';
|
||||
|
||||
/** Usage statistics from CLIProxyAPI */
|
||||
export interface CliproxyStats {
|
||||
@@ -70,7 +70,7 @@ export async function fetchCliproxyStats(
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `Bearer ${CCS_INTERNAL_API_KEY}`,
|
||||
Authorization: `Bearer ${CCS_CONTROL_PANEL_SECRET}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -118,6 +118,81 @@ export async function fetchCliproxyStats(
|
||||
}
|
||||
}
|
||||
|
||||
/** OpenAI-compatible model object from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
/** Response from /v1/models endpoint */
|
||||
interface ModelsApiResponse {
|
||||
data: CliproxyModel[];
|
||||
object: string;
|
||||
}
|
||||
|
||||
/** Categorized models response for UI */
|
||||
export interface CliproxyModelsResponse {
|
||||
models: CliproxyModel[];
|
||||
byCategory: Record<string, CliproxyModel[]>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available models from CLIProxyAPI /v1/models endpoint
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
* @returns Categorized models or null if unavailable
|
||||
*/
|
||||
export async function fetchCliproxyModels(
|
||||
port: number = CLIPROXY_DEFAULT_PORT
|
||||
): Promise<CliproxyModelsResponse | null> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 3000);
|
||||
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/models`, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
// Use the internal API key for /v1 endpoints
|
||||
Authorization: 'Bearer ccs-internal-managed',
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = (await response.json()) as ModelsApiResponse;
|
||||
|
||||
// Group models by owned_by field
|
||||
const byCategory: Record<string, CliproxyModel[]> = {};
|
||||
for (const model of data.data) {
|
||||
const category = model.owned_by || 'other';
|
||||
if (!byCategory[category]) {
|
||||
byCategory[category] = [];
|
||||
}
|
||||
byCategory[category].push(model);
|
||||
}
|
||||
|
||||
// Sort models within each category alphabetically
|
||||
for (const category of Object.keys(byCategory)) {
|
||||
byCategory[category].sort((a, b) => a.id.localeCompare(b.id));
|
||||
}
|
||||
|
||||
return {
|
||||
models: data.data,
|
||||
byCategory,
|
||||
totalCount: data.data.length,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if CLIProxyAPI is running and responsive
|
||||
* @param port CLIProxyAPI port (default: 8317)
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
* Config Command Handler
|
||||
*
|
||||
* Launches web-based configuration dashboard.
|
||||
* Ensures CLIProxy service is running for dashboard features.
|
||||
* Usage: ccs config [--port PORT] [--dev]
|
||||
*/
|
||||
|
||||
@@ -9,7 +10,9 @@ import getPort from 'get-port';
|
||||
import open from 'open';
|
||||
import { startServer } from '../web-server';
|
||||
import { setupGracefulShutdown } from '../web-server/shutdown';
|
||||
import { initUI, header, ok, info } from '../utils/ui';
|
||||
import { ensureCliproxyService } from '../cliproxy/service-manager';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||
import { initUI, header, ok, info, warn } from '../utils/ui';
|
||||
|
||||
interface ConfigOptions {
|
||||
port?: number;
|
||||
@@ -72,10 +75,31 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
||||
await initUI();
|
||||
|
||||
const options = parseArgs(args);
|
||||
const verbose = options.dev || false;
|
||||
|
||||
console.log(header('CCS Config Dashboard'));
|
||||
console.log('');
|
||||
console.log(info('Starting server...'));
|
||||
|
||||
// Ensure CLIProxy service is running for dashboard features
|
||||
console.log(info('Starting CLIProxy service...'));
|
||||
const cliproxyResult = await ensureCliproxyService(CLIPROXY_DEFAULT_PORT, verbose);
|
||||
|
||||
if (cliproxyResult.started) {
|
||||
if (cliproxyResult.alreadyRunning) {
|
||||
console.log(ok(`CLIProxy already running on port ${cliproxyResult.port}`));
|
||||
if (cliproxyResult.configRegenerated) {
|
||||
console.log(warn('Config updated - restart CLIProxy to apply changes'));
|
||||
}
|
||||
} else {
|
||||
console.log(ok(`CLIProxy started on port ${cliproxyResult.port}`));
|
||||
}
|
||||
} else {
|
||||
console.log(warn(`CLIProxy not available: ${cliproxyResult.error}`));
|
||||
console.log(info('Dashboard will work but Control Panel/Stats may be limited'));
|
||||
}
|
||||
console.log('');
|
||||
|
||||
console.log(info('Starting dashboard server...'));
|
||||
|
||||
// Find available port
|
||||
const port =
|
||||
|
||||
@@ -52,12 +52,26 @@ export interface Config {
|
||||
export type EnvValue = string;
|
||||
export type EnvVars = Record<string, EnvValue>;
|
||||
|
||||
/**
|
||||
* Model preset configuration
|
||||
* Stores named model mappings for quick switching
|
||||
*/
|
||||
export interface ModelPreset {
|
||||
name: string;
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude CLI settings.json structure
|
||||
* Located at: ~/.claude/settings.json or profile-specific
|
||||
*/
|
||||
export interface Settings {
|
||||
env?: EnvVars;
|
||||
/** Saved model presets for this provider */
|
||||
presets?: ModelPreset[];
|
||||
[key: string]: unknown; // Allow other settings
|
||||
}
|
||||
|
||||
|
||||
+124
-1
@@ -12,7 +12,11 @@ import { Config, Settings } from '../types/config';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
||||
import { fetchCliproxyStats, isCliproxyRunning } from '../cliproxy/stats-fetcher';
|
||||
import {
|
||||
fetchCliproxyStats,
|
||||
fetchCliproxyModels,
|
||||
isCliproxyRunning,
|
||||
} from '../cliproxy/stats-fetcher';
|
||||
import {
|
||||
listOpenAICompatProviders,
|
||||
getOpenAICompatProvider,
|
||||
@@ -668,6 +672,93 @@ apiRoutes.put('/settings/:profile', (req: Request, res: Response): void => {
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== Presets ====================
|
||||
|
||||
/**
|
||||
* GET /api/settings/:profile/presets - Get saved presets for a provider
|
||||
*/
|
||||
apiRoutes.get('/settings/:profile/presets', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.json({ presets: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
res.json({ presets: settings.presets || [] });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/settings/:profile/presets - Create a new preset
|
||||
*/
|
||||
apiRoutes.post('/settings/:profile/presets', (req: Request, res: Response): void => {
|
||||
const { profile } = req.params;
|
||||
const { name, default: defaultModel, opus, sonnet, haiku } = req.body;
|
||||
|
||||
if (!name || !defaultModel) {
|
||||
res.status(400).json({ error: 'Missing required fields: name, default' });
|
||||
return;
|
||||
}
|
||||
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
// Create settings file if it doesn't exist
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
fs.writeFileSync(settingsPath, JSON.stringify({ env: {}, presets: [] }, null, 2) + '\n');
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
settings.presets = settings.presets || [];
|
||||
|
||||
// Check for duplicate name
|
||||
if (settings.presets.some((p) => p.name === name)) {
|
||||
res.status(409).json({ error: 'Preset with this name already exists' });
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = {
|
||||
name,
|
||||
default: defaultModel,
|
||||
opus: opus || defaultModel,
|
||||
sonnet: sonnet || defaultModel,
|
||||
haiku: haiku || defaultModel,
|
||||
};
|
||||
|
||||
settings.presets.push(preset);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.status(201).json({ preset });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/settings/:profile/presets/:name - Delete a preset
|
||||
*/
|
||||
apiRoutes.delete('/settings/:profile/presets/:name', (req: Request, res: Response): void => {
|
||||
const { profile, name } = req.params;
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
|
||||
|
||||
if (!fs.existsSync(settingsPath)) {
|
||||
res.status(404).json({ error: 'Settings not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = loadSettings(settingsPath);
|
||||
if (!settings.presets || !settings.presets.some((p) => p.name === name)) {
|
||||
res.status(404).json({ error: 'Preset not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
settings.presets = settings.presets.filter((p) => p.name !== name);
|
||||
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// ==================== Accounts ====================
|
||||
|
||||
/**
|
||||
@@ -1079,6 +1170,38 @@ apiRoutes.get('/cliproxy/status', async (_req: Request, res: Response): Promise<
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/cliproxy/models - Get available models from CLIProxyAPI
|
||||
* Returns: { models: CliproxyModel[], byCategory: Record<string, CliproxyModel[]>, totalCount: number }
|
||||
*/
|
||||
apiRoutes.get('/cliproxy/models', async (_req: Request, res: Response): Promise<void> => {
|
||||
try {
|
||||
// Check if proxy is running first
|
||||
const running = await isCliproxyRunning();
|
||||
if (!running) {
|
||||
res.status(503).json({
|
||||
error: 'CLIProxyAPI not running',
|
||||
message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch models from /v1/models endpoint
|
||||
const modelsResponse = await fetchCliproxyModels();
|
||||
if (!modelsResponse) {
|
||||
res.status(503).json({
|
||||
error: 'Models unavailable',
|
||||
message: 'CLIProxyAPI is running but /v1/models endpoint not responding',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(modelsResponse);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// OpenAI Compatibility Layer Routes
|
||||
// ============================================
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Gemini</title><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="#3186FF"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-0)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-1)"></path><path d="M20.616 10.835a14.147 14.147 0 01-4.45-3.001 14.111 14.111 0 01-3.678-6.452.503.503 0 00-.975 0 14.134 14.134 0 01-3.679 6.452 14.155 14.155 0 01-4.45 3.001c-.65.28-1.318.505-2.002.678a.502.502 0 000 .975c.684.172 1.35.397 2.002.677a14.147 14.147 0 014.45 3.001 14.112 14.112 0 013.679 6.453.502.502 0 00.975 0c.172-.685.397-1.351.677-2.003a14.145 14.145 0 013.001-4.45 14.113 14.113 0 016.453-3.678.503.503 0 000-.975 13.245 13.245 0 01-2.003-.678z" fill="url(#lobe-icons-gemini-fill-2)"></path><defs><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-0" x1="7" x2="11" y1="15.5" y2="12"><stop stop-color="#08B962"></stop><stop offset="1" stop-color="#08B962" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-1" x1="8" x2="11.5" y1="5.5" y2="11"><stop stop-color="#F94543"></stop><stop offset="1" stop-color="#F94543" stop-opacity="0"></stop></linearGradient><linearGradient gradientUnits="userSpaceOnUse" id="lobe-icons-gemini-fill-2" x1="3.5" x2="17.5" y1="13.5" y2="12"><stop stop-color="#FABC12"></stop><stop offset=".46" stop-color="#FABC12" stop-opacity="0"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.8 KiB |
@@ -0,0 +1 @@
|
||||
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>OpenAI</title><path d="M21.55 10.004a5.416 5.416 0 00-.478-4.501c-1.217-2.09-3.662-3.166-6.05-2.66A5.59 5.59 0 0010.831 1C8.39.995 6.224 2.546 5.473 4.838A5.553 5.553 0 001.76 7.496a5.487 5.487 0 00.691 6.5 5.416 5.416 0 00.477 4.502c1.217 2.09 3.662 3.165 6.05 2.66A5.586 5.586 0 0013.168 23c2.443.006 4.61-1.546 5.361-3.84a5.553 5.553 0 003.715-2.66 5.488 5.488 0 00-.693-6.497v.001zm-8.381 11.558a4.199 4.199 0 01-2.675-.954c.034-.018.093-.05.132-.074l4.44-2.53a.71.71 0 00.364-.623v-6.176l1.877 1.069c.02.01.033.029.036.05v5.115c-.003 2.274-1.87 4.118-4.174 4.123zM4.192 17.78a4.059 4.059 0 01-.498-2.763c.032.02.09.055.131.078l4.44 2.53c.225.13.504.13.73 0l5.42-3.088v2.138a.068.068 0 01-.027.057L9.9 19.288c-1.999 1.136-4.552.46-5.707-1.51h-.001zM3.023 8.216A4.15 4.15 0 015.198 6.41l-.002.151v5.06a.711.711 0 00.364.624l5.42 3.087-1.876 1.07a.067.067 0 01-.063.005l-4.489-2.559c-1.995-1.14-2.679-3.658-1.53-5.63h.001zm15.417 3.54l-5.42-3.088L14.896 7.6a.067.067 0 01.063-.006l4.489 2.557c1.998 1.14 2.683 3.662 1.529 5.633a4.163 4.163 0 01-2.174 1.807V12.38a.71.71 0 00-.363-.623zm1.867-2.773a6.04 6.04 0 00-.132-.078l-4.44-2.53a.731.731 0 00-.729 0l-5.42 3.088V7.325a.068.068 0 01.027-.057L14.1 4.713c2-1.137 4.555-.46 5.707 1.513.487.833.664 1.809.499 2.757h.001zm-11.741 3.81l-1.877-1.068a.065.065 0 01-.036-.051V6.559c.001-2.277 1.873-4.122 4.181-4.12.976 0 1.92.338 2.671.954-.034.018-.092.05-.131.073l-4.44 2.53a.71.71 0 00-.365.623l-.003 6.173v.002zm1.02-2.168L12 9.25l2.414 1.375v2.75L12 14.75l-2.415-1.375v-2.75z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qwen</title><path d="M12.604 1.34c.393.69.784 1.382 1.174 2.075a.18.18 0 00.157.091h5.552c.174 0 .322.11.446.327l1.454 2.57c.19.337.24.478.024.837-.26.43-.513.864-.76 1.3l-.367.658c-.106.196-.223.28-.04.512l2.652 4.637c.172.301.111.494-.043.77-.437.785-.882 1.564-1.335 2.34-.159.272-.352.375-.68.37-.777-.016-1.552-.01-2.327.016a.099.099 0 00-.081.05 575.097 575.097 0 01-2.705 4.74c-.169.293-.38.363-.725.364-.997.003-2.002.004-3.017.002a.537.537 0 01-.465-.271l-1.335-2.323a.09.09 0 00-.083-.049H4.982c-.285.03-.553-.001-.805-.092l-1.603-2.77a.543.543 0 01-.002-.54l1.207-2.12a.198.198 0 000-.197 550.951 550.951 0 01-1.875-3.272l-.79-1.395c-.16-.31-.173-.496.095-.965.465-.813.927-1.625 1.387-2.436.132-.234.304-.334.584-.335a338.3 338.3 0 012.589-.001.124.124 0 00.107-.063l2.806-4.895a.488.488 0 01.422-.246c.524-.001 1.053 0 1.583-.006L11.704 1c.341-.003.724.032.9.34zm-3.432.403a.06.06 0 00-.052.03L6.254 6.788a.157.157 0 01-.135.078H3.253c-.056 0-.07.025-.041.074l5.81 10.156c.025.042.013.062-.034.063l-2.795.015a.218.218 0 00-.2.116l-1.32 2.31c-.044.078-.021.118.068.118l5.716.008c.046 0 .08.02.104.061l1.403 2.454c.046.081.092.082.139 0l5.006-8.76.783-1.382a.055.055 0 01.096 0l1.424 2.53a.122.122 0 00.107.062l2.763-.02a.04.04 0 00.035-.02.041.041 0 000-.04l-2.9-5.086a.108.108 0 010-.113l.293-.507 1.12-1.977c.024-.041.012-.062-.035-.062H9.2c-.059 0-.073-.026-.043-.077l1.434-2.505a.107.107 0 000-.114L9.225 1.774a.06.06 0 00-.053-.031zm6.29 8.02c.046 0 .058.02.034.06l-.832 1.465-2.613 4.585a.056.056 0 01-.05.029.058.058 0 01-.05-.029L8.498 9.841c-.02-.034-.01-.052.028-.054l.216-.012 6.722-.012z" fill="url(#lobe-icons-qwen-fill)" fill-rule="nonzero"></path><defs><linearGradient id="lobe-icons-qwen-fill" x1="0%" x2="100%" y1="0%" y2="0%"><stop offset="0%" stop-color="#6336E7" stop-opacity=".84"></stop><stop offset="100%" stop-color="#6F69F7" stop-opacity=".84"></stop></linearGradient></defs></svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
@@ -20,6 +20,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa
|
||||
const CliproxyPage = lazy(() =>
|
||||
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
||||
);
|
||||
const CliproxyControlPanelPage = lazy(() =>
|
||||
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
|
||||
);
|
||||
const AccountsPage = lazy(() =>
|
||||
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
||||
);
|
||||
@@ -71,6 +74,7 @@ export default function App() {
|
||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||
<Route path="/api" element={<ApiPage />} />
|
||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
|
||||
<Route path="/accounts" element={<AccountsPage />} />
|
||||
<Route path="/settings" element={<SettingsPage />} />
|
||||
<Route path="/health" element={<HealthPage />} />
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
FolderOpen,
|
||||
ChevronRight,
|
||||
BarChart3,
|
||||
Gauge,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Sidebar,
|
||||
@@ -43,7 +44,16 @@ const navGroups = [
|
||||
title: 'Identity & Access',
|
||||
items: [
|
||||
{ path: '/api', icon: Key, label: 'API Profiles' },
|
||||
{ path: '/cliproxy', icon: Zap, label: 'CLIProxy' },
|
||||
{
|
||||
path: '/cliproxy',
|
||||
icon: Zap,
|
||||
label: 'CLIProxy',
|
||||
isCollapsible: true,
|
||||
children: [
|
||||
{ path: '/cliproxy', label: 'Overview' },
|
||||
{ path: '/cliproxy/control-panel', icon: Gauge, label: 'Control Panel' },
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/accounts',
|
||||
icon: Users,
|
||||
@@ -69,12 +79,15 @@ export function AppSidebar() {
|
||||
const location = useLocation();
|
||||
const { state } = useSidebar();
|
||||
|
||||
// Helper to check if a route is active (including sub-routes if needed)
|
||||
// Helper to check if a route is active (exact match)
|
||||
const isRouteActive = (path: string) => location.pathname === path;
|
||||
|
||||
// Helper to check if a group/parent should be open based on active child
|
||||
// Also handles sub-routes (e.g., /cliproxy/control-panel matches /cliproxy)
|
||||
const isParentActive = (children: { path: string }[]) => {
|
||||
return children.some((child) => isRouteActive(child.path));
|
||||
return children.some(
|
||||
(child) => isRouteActive(child.path) || location.pathname.startsWith(child.path + '/')
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* Categorized Model Selector
|
||||
* Groups models by provider (owned_by) with model counts
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Cpu } from 'lucide-react';
|
||||
import type { CliproxyModelsResponse } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Provider display configuration */
|
||||
const CATEGORY_CONFIG: Record<string, { name: string; color: string }> = {
|
||||
google: { name: 'Google (Gemini)', color: 'text-blue-600' },
|
||||
openai: { name: 'OpenAI (GPT)', color: 'text-green-600' },
|
||||
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600' },
|
||||
antigravity: { name: 'Antigravity', color: 'text-purple-600' },
|
||||
other: { name: 'Other', color: 'text-gray-600' },
|
||||
};
|
||||
|
||||
/** Get display name for category */
|
||||
function getCategoryDisplay(category: string) {
|
||||
return (
|
||||
CATEGORY_CONFIG[category.toLowerCase()] || {
|
||||
name: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
color: 'text-gray-600',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CategorizedModelSelectorProps {
|
||||
/** Models data from API */
|
||||
modelsData: CliproxyModelsResponse | undefined;
|
||||
/** Whether data is loading */
|
||||
isLoading?: boolean;
|
||||
/** Currently selected model */
|
||||
value: string | undefined;
|
||||
/** Callback when model changes */
|
||||
onChange: (model: string) => void;
|
||||
/** Whether the selector is disabled */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Custom width class */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function CategorizedModelSelector({
|
||||
modelsData,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Select a model',
|
||||
className,
|
||||
}: CategorizedModelSelectorProps) {
|
||||
// Sort categories by model count (descending)
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!modelsData?.byCategory) return [];
|
||||
return Object.entries(modelsData.byCategory)
|
||||
.sort(([, a], [, b]) => b.length - a.length)
|
||||
.map(([category, models]) => ({
|
||||
category,
|
||||
display: getCategoryDisplay(category),
|
||||
models,
|
||||
}));
|
||||
}, [modelsData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-9 w-[280px]', className)} />;
|
||||
}
|
||||
|
||||
if (!modelsData || modelsData.totalCount === 0) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 text-sm text-muted-foreground', className)}>
|
||||
<Cpu className="w-4 h-4" />
|
||||
<span>No models available</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className={cn('w-[320px]', className)}>
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{value && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{value}</span>
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[400px]">
|
||||
{sortedCategories.map(({ category, display, models }) => (
|
||||
<SelectGroup key={category}>
|
||||
<SelectLabel className="flex items-center justify-between px-2 py-1.5">
|
||||
<span className={cn('font-semibold', display.color)}>{display.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 ml-2">
|
||||
{models.length}
|
||||
</Badge>
|
||||
</SelectLabel>
|
||||
{models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id} className="pl-4">
|
||||
<span className="truncate">{model.id}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
|
||||
/** Compact variant for inline usage */
|
||||
export function CategorizedModelSelectorCompact({
|
||||
modelsData,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: Omit<CategorizedModelSelectorProps, 'placeholder' | 'className'>) {
|
||||
return (
|
||||
<CategorizedModelSelector
|
||||
modelsData={modelsData}
|
||||
isLoading={isLoading}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder="Model..."
|
||||
className="w-[200px]"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* CLIProxy Control Panel Embed
|
||||
*
|
||||
* Embeds the CLIProxy management.html with auto-authentication.
|
||||
* Uses postMessage to inject credentials into the iframe.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { RefreshCw, AlertCircle, Key, X, Gauge } from 'lucide-react';
|
||||
|
||||
/** CLIProxyAPI default port */
|
||||
const CLIPROXY_DEFAULT_PORT = 8317;
|
||||
|
||||
/** CCS Control Panel secret - must match config-generator.ts CCS_CONTROL_PANEL_SECRET */
|
||||
const CCS_CONTROL_PANEL_SECRET = 'ccs';
|
||||
|
||||
interface ControlPanelEmbedProps {
|
||||
port?: number;
|
||||
}
|
||||
|
||||
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isConnected, setIsConnected] = useState(false);
|
||||
const [showLoginHint, setShowLoginHint] = useState(true);
|
||||
|
||||
const managementUrl = `http://localhost:${port}/management.html`;
|
||||
|
||||
// Check if CLIProxy is running
|
||||
useEffect(() => {
|
||||
const checkConnection = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${port}/`, {
|
||||
signal: AbortSignal.timeout(2000),
|
||||
});
|
||||
if (response.ok) {
|
||||
setIsConnected(true);
|
||||
setError(null);
|
||||
} else {
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy returned an error');
|
||||
}
|
||||
} catch {
|
||||
setIsConnected(false);
|
||||
setError('CLIProxy is not running');
|
||||
}
|
||||
};
|
||||
|
||||
checkConnection();
|
||||
}, [port]);
|
||||
|
||||
// Handle iframe load - attempt to auto-login via postMessage
|
||||
const handleIframeLoad = useCallback(() => {
|
||||
setIsLoading(false);
|
||||
|
||||
// Try to inject credentials via postMessage
|
||||
// The management.html needs to listen for this message
|
||||
// If it doesn't support it, user will see the login page
|
||||
if (iframeRef.current?.contentWindow) {
|
||||
try {
|
||||
// Send credentials to iframe
|
||||
iframeRef.current.contentWindow.postMessage(
|
||||
{
|
||||
type: 'ccs-auto-login',
|
||||
apiBase: `http://localhost:${port}`,
|
||||
managementKey: CCS_CONTROL_PANEL_SECRET,
|
||||
},
|
||||
`http://localhost:${port}`
|
||||
);
|
||||
} catch {
|
||||
// Cross-origin restriction - expected if not same origin
|
||||
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin');
|
||||
}
|
||||
}
|
||||
}, [port]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
setIsLoading(true);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.src = managementUrl;
|
||||
}
|
||||
};
|
||||
|
||||
// Show error state if CLIProxy is not running
|
||||
if (!isConnected && error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="flex items-center justify-between p-4 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<Gauge className="w-5 h-5 text-primary" />
|
||||
<h2 className="font-semibold">CLIProxy Control Panel</h2>
|
||||
</div>
|
||||
<button
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm border rounded-md hover:bg-muted"
|
||||
onClick={handleRefresh}
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center max-w-md px-8">
|
||||
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
|
||||
<AlertCircle className="w-8 h-8 text-destructive" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold mb-2">CLIProxy Not Available</h3>
|
||||
<p className="text-muted-foreground mb-4">{error}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Start a CLIProxy session with{' '}
|
||||
<code className="bg-muted px-1 rounded">ccs gemini</code> or run{' '}
|
||||
<code className="bg-muted px-1 rounded">ccs config</code> which auto-starts it.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
ccs
|
||||
</code>
|
||||
</span>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">Loading Control Panel...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,74 +1,32 @@
|
||||
/**
|
||||
* Model Preferences Grid Component
|
||||
* Model selection per provider for CLIProxy Overview tab
|
||||
* Displays all available models categorized by provider source
|
||||
*/
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useCliproxyModels, useUpdateModel } from '@/hooks/use-cliproxy';
|
||||
import { useMemo } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Cpu, AlertCircle } from 'lucide-react';
|
||||
import { useCliproxyModels } from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProviderModelSelectProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
currentModel: string;
|
||||
availableModels: string[];
|
||||
onModelChange: (model: string) => void;
|
||||
isUpdating: boolean;
|
||||
}
|
||||
/** Category display configuration */
|
||||
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = {
|
||||
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
||||
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' },
|
||||
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
||||
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
||||
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
|
||||
};
|
||||
|
||||
function ProviderIcon({ provider }: { provider: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
claude: 'bg-orange-500',
|
||||
gemini: 'bg-blue-500',
|
||||
codex: 'bg-green-500',
|
||||
agy: 'bg-purple-500',
|
||||
};
|
||||
return <div className={`w-3 h-3 rounded-full ${colors[provider] ?? 'bg-gray-500'}`} />;
|
||||
}
|
||||
|
||||
function ProviderModelSelect({
|
||||
provider,
|
||||
displayName,
|
||||
currentModel,
|
||||
availableModels,
|
||||
onModelChange,
|
||||
isUpdating,
|
||||
}: ProviderModelSelectProps) {
|
||||
function getCategoryDisplay(category: string) {
|
||||
return (
|
||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<ProviderIcon provider={provider} />
|
||||
<span className="font-medium">{displayName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{isUpdating && <Loader2 className="w-4 h-4 animate-spin text-muted-foreground" />}
|
||||
<Select
|
||||
value={currentModel}
|
||||
onValueChange={onModelChange}
|
||||
disabled={isUpdating || availableModels.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue
|
||||
placeholder={availableModels.length === 0 ? 'No models available' : 'Select model'}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableModels.map((model) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
CATEGORY_CONFIG[category.toLowerCase()] || {
|
||||
name: category.charAt(0).toUpperCase() + category.slice(1),
|
||||
color: 'text-gray-600',
|
||||
bgColor: 'bg-gray-50',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,12 +34,12 @@ function ModelPreferencesSkeleton() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Model Preferences</CardTitle>
|
||||
<CardTitle className="text-base">Available Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -89,50 +47,93 @@ function ModelPreferencesSkeleton() {
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPreferencesGrid() {
|
||||
const { data: modelsData, isLoading } = useCliproxyModels();
|
||||
const updateModel = useUpdateModel();
|
||||
|
||||
const providers = [
|
||||
{ id: 'claude', displayName: 'Claude' },
|
||||
{ id: 'gemini', displayName: 'Gemini' },
|
||||
{ id: 'codex', displayName: 'Codex' },
|
||||
{ id: 'agy', displayName: 'Agy' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <ModelPreferencesSkeleton />;
|
||||
}
|
||||
|
||||
const getProviderModels = (providerId: string) => {
|
||||
const providerData = modelsData?.providers?.[providerId];
|
||||
return {
|
||||
current: providerData?.currentModel ?? '',
|
||||
available: providerData?.availableModels ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
function EmptyModelsState() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">Model Preferences</CardTitle>
|
||||
<CardTitle className="text-base">Available Models</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{providers.map((p) => {
|
||||
const models = getProviderModels(p.id);
|
||||
return (
|
||||
<ProviderModelSelect
|
||||
key={p.id}
|
||||
provider={p.id}
|
||||
displayName={p.displayName}
|
||||
currentModel={models.current}
|
||||
availableModels={models.available}
|
||||
onModelChange={(model) => updateModel.mutate({ provider: p.id, model })}
|
||||
isUpdating={updateModel.isPending && updateModel.variables?.provider === p.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||
<AlertCircle className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-1">No models available</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Start a CLIProxy session to fetch available models
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function ModelPreferencesGrid() {
|
||||
const { data: modelsData, isLoading, isError } = useCliproxyModels();
|
||||
|
||||
// Sort categories by model count
|
||||
const sortedCategories = useMemo(() => {
|
||||
if (!modelsData?.byCategory) return [];
|
||||
return Object.entries(modelsData.byCategory)
|
||||
.sort(([, a], [, b]) => b.length - a.length)
|
||||
.map(([category, models]) => ({
|
||||
category,
|
||||
display: getCategoryDisplay(category),
|
||||
models,
|
||||
}));
|
||||
}, [modelsData]);
|
||||
|
||||
if (isLoading) {
|
||||
return <ModelPreferencesSkeleton />;
|
||||
}
|
||||
|
||||
if (isError || !modelsData || modelsData.totalCount === 0) {
|
||||
return <EmptyModelsState />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Cpu className="w-4 h-4" />
|
||||
Available Models
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{modelsData.totalCount} total
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>Models available through CLIProxyAPI, grouped by provider</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{sortedCategories.map(({ category, display, models }) => (
|
||||
<div
|
||||
key={category}
|
||||
className={cn('p-4 rounded-lg border', display.bgColor, 'border-transparent')}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className={cn('font-medium text-sm', display.color)}>{display.name}</h3>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{models.length}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-32 overflow-y-auto">
|
||||
{models.slice(0, 5).map((model) => (
|
||||
<div
|
||||
key={model.id}
|
||||
className="text-xs text-muted-foreground truncate"
|
||||
title={model.id}
|
||||
>
|
||||
{model.id}
|
||||
</div>
|
||||
))}
|
||||
{models.length > 5 && (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
+{models.length - 5} more
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,897 @@
|
||||
/**
|
||||
* Provider Editor Component
|
||||
* Split-view editor for CLIProxy provider settings
|
||||
* Similar to ProfileEditor but tailored for provider configuration
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
Code2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Info,
|
||||
X,
|
||||
Shield,
|
||||
User,
|
||||
Plus,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { ProviderLogo } from './provider-logo';
|
||||
import { FlexibleModelSelector } from './provider-model-selector';
|
||||
import type { ProviderCatalog } from './provider-model-selector';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import {
|
||||
useCliproxyModels,
|
||||
usePresets,
|
||||
useCreatePreset,
|
||||
useDeletePreset,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Lazy load CodeEditor
|
||||
const CodeEditor = lazy(() =>
|
||||
import('@/components/code-editor').then((m) => ({ default: m.CodeEditor }))
|
||||
);
|
||||
|
||||
interface SettingsResponse {
|
||||
profile: string;
|
||||
settings: {
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
mtime: number;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface ProviderEditorProps {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
authStatus: AuthStatus;
|
||||
catalog?: ProviderCatalog;
|
||||
/** Provider type for logo display (defaults to provider) */
|
||||
logoProvider?: string;
|
||||
onAddAccount: () => void;
|
||||
onSetDefault: (accountId: string) => void;
|
||||
onRemoveAccount: (accountId: string) => void;
|
||||
isRemovingAccount?: boolean;
|
||||
}
|
||||
|
||||
export function ProviderEditor({
|
||||
provider,
|
||||
displayName,
|
||||
authStatus,
|
||||
catalog,
|
||||
logoProvider,
|
||||
onAddAccount,
|
||||
onSetDefault,
|
||||
onRemoveAccount,
|
||||
isRemovingAccount,
|
||||
}: ProviderEditorProps) {
|
||||
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||
const [conflictDialog, setConflictDialog] = useState(false);
|
||||
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Fetch available models from CLIProxy API
|
||||
const { data: modelsData } = useCliproxyModels();
|
||||
|
||||
// Fetch saved presets for this provider
|
||||
const { data: presetsData } = usePresets(provider);
|
||||
const createPresetMutation = useCreatePreset();
|
||||
const deletePresetMutation = useDeletePreset();
|
||||
const savedPresets = presetsData?.presets || [];
|
||||
|
||||
// Get models for this provider based on owned_by field
|
||||
const providerModels = useMemo(() => {
|
||||
if (!modelsData?.models) return [];
|
||||
const ownerMap: Record<string, string[]> = {
|
||||
gemini: ['google'],
|
||||
agy: ['antigravity'],
|
||||
codex: ['openai'],
|
||||
qwen: ['alibaba', 'qwen'],
|
||||
iflow: ['iflow'],
|
||||
};
|
||||
const owners = ownerMap[provider.toLowerCase()] || [provider.toLowerCase()];
|
||||
return modelsData.models.filter((m) =>
|
||||
owners.some((o) => m.owned_by.toLowerCase().includes(o))
|
||||
);
|
||||
}, [modelsData, provider]);
|
||||
|
||||
// Fetch settings for this provider
|
||||
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
|
||||
queryKey: ['settings', provider],
|
||||
queryFn: async () => {
|
||||
const res = await fetch(`/api/settings/${provider}/raw`);
|
||||
if (!res.ok) {
|
||||
// Return empty settings for unconfigured providers
|
||||
return {
|
||||
profile: provider,
|
||||
settings: { env: {} },
|
||||
mtime: Date.now(),
|
||||
path: `~/.ccs/profiles/${provider}/settings.json`,
|
||||
};
|
||||
}
|
||||
return res.json();
|
||||
},
|
||||
});
|
||||
|
||||
const settings = data?.settings;
|
||||
|
||||
// Derive raw JSON content
|
||||
const rawJsonContent = useMemo(() => {
|
||||
if (rawJsonEdits !== null) return rawJsonEdits;
|
||||
if (settings) return JSON.stringify(settings, null, 2);
|
||||
return '{\n "env": {}\n}';
|
||||
}, [rawJsonEdits, settings]);
|
||||
|
||||
const handleRawJsonChange = useCallback((value: string) => {
|
||||
setRawJsonEdits(value);
|
||||
}, []);
|
||||
|
||||
// Parse current settings from JSON
|
||||
const currentSettings = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(rawJsonContent);
|
||||
} catch {
|
||||
return settings || { env: {} };
|
||||
}
|
||||
}, [rawJsonContent, settings]);
|
||||
|
||||
// Extract model values from settings
|
||||
const currentModel = currentSettings?.env?.ANTHROPIC_MODEL;
|
||||
const opusModel = currentSettings?.env?.ANTHROPIC_DEFAULT_OPUS_MODEL;
|
||||
const sonnetModel = currentSettings?.env?.ANTHROPIC_DEFAULT_SONNET_MODEL;
|
||||
const haikuModel = currentSettings?.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
|
||||
// Update a setting value
|
||||
const updateEnvValue = (key: string, value: string) => {
|
||||
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
||||
const newSettings = { ...currentSettings, env: newEnv };
|
||||
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||
};
|
||||
|
||||
// Batch update multiple env values at once
|
||||
const updateEnvValues = (updates: Record<string, string>) => {
|
||||
const newEnv = { ...(currentSettings?.env || {}), ...updates };
|
||||
const newSettings = { ...currentSettings, env: newEnv };
|
||||
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||
};
|
||||
|
||||
// Check if JSON is valid
|
||||
const isRawJsonValid = useMemo(() => {
|
||||
try {
|
||||
JSON.parse(rawJsonContent);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [rawJsonContent]);
|
||||
|
||||
// Check for unsaved changes
|
||||
const hasChanges = useMemo(() => {
|
||||
if (rawJsonEdits === null) return false;
|
||||
return rawJsonEdits !== JSON.stringify(settings, null, 2);
|
||||
}, [rawJsonEdits, settings]);
|
||||
|
||||
// Save mutation
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const settingsToSave = JSON.parse(rawJsonContent);
|
||||
const res = await fetch(`/api/settings/${provider}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
settings: settingsToSave,
|
||||
expectedMtime: data?.mtime,
|
||||
}),
|
||||
});
|
||||
|
||||
if (res.status === 409) throw new Error('CONFLICT');
|
||||
if (!res.ok) throw new Error('Failed to save');
|
||||
return res.json();
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['settings', provider] });
|
||||
setRawJsonEdits(null);
|
||||
toast.success('Settings saved');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
if (error.message === 'CONFLICT') {
|
||||
setConflictDialog(true);
|
||||
} else {
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const handleConflictResolve = async (overwrite: boolean) => {
|
||||
setConflictDialog(false);
|
||||
if (overwrite) {
|
||||
await refetch();
|
||||
saveMutation.mutate();
|
||||
} else {
|
||||
setRawJsonEdits(null);
|
||||
}
|
||||
};
|
||||
|
||||
const accounts = authStatus.accounts || [];
|
||||
|
||||
// Render Left Column - Model Config + Info tabs
|
||||
const renderFriendlyUI = () => (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs defaultValue="config" className="h-full flex flex-col">
|
||||
<div className="px-4 pt-4 shrink-0">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="config" className="flex-1">
|
||||
Model Config
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
Info & Usage
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{/* Model Config Tab */}
|
||||
<TabsContent
|
||||
value="config"
|
||||
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||
>
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Quick Presets */}
|
||||
{(catalog && catalog.models.length > 0) || savedPresets.length > 0 ? (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2 flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Presets
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Apply pre-configured model mappings
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{/* Recommended presets from catalog */}
|
||||
{catalog?.models.slice(0, 3).map((model) => (
|
||||
<Button
|
||||
key={model.id}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1"
|
||||
onClick={() => {
|
||||
const mapping = model.presetMapping || {
|
||||
default: model.id,
|
||||
opus: model.id,
|
||||
sonnet: model.id,
|
||||
haiku: model.id,
|
||||
};
|
||||
updateEnvValues({
|
||||
ANTHROPIC_MODEL: mapping.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||
});
|
||||
toast.success(`Applied "${model.name}" preset`);
|
||||
}}
|
||||
>
|
||||
<Zap className="w-3 h-3" />
|
||||
{model.name}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{/* User saved presets */}
|
||||
{savedPresets.map((preset) => (
|
||||
<div key={preset.name} className="group relative">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 pr-6"
|
||||
onClick={() => {
|
||||
updateEnvValues({
|
||||
ANTHROPIC_MODEL: preset.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||
});
|
||||
toast.success(`Applied "${preset.name}" preset`);
|
||||
}}
|
||||
>
|
||||
<Star className="w-3 h-3 fill-current" />
|
||||
{preset.name}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-7 w-5 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deletePresetMutation.mutate({ profile: provider, name: preset.name });
|
||||
}}
|
||||
disabled={deletePresetMutation.isPending}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs h-7 gap-1 border border-dashed"
|
||||
onClick={() => setCustomPresetOpen(true)}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Custom
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Model Mapping */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-2">Model Mapping</h3>
|
||||
<p className="text-xs text-muted-foreground mb-4">
|
||||
Configure which models to use for each tier
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={currentModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={opusModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={sonnetModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={haikuModel}
|
||||
onChange={(model) => updateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Accounts Section */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-medium flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Accounts
|
||||
{accounts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{accounts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</h3>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs gap-1"
|
||||
onClick={onAddAccount}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{accounts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<AccountItem
|
||||
key={account.id}
|
||||
account={account}
|
||||
onSetDefault={() => onSetDefault(account.id)}
|
||||
onRemove={() => onRemoveAccount(account.id)}
|
||||
isRemoving={isRemovingAccount}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-6 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
|
||||
<User className="w-8 h-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">No accounts connected</p>
|
||||
<p className="text-xs opacity-70">Add an account to get started</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
|
||||
{/* Info Tab */}
|
||||
<TabsContent
|
||||
value="info"
|
||||
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
|
||||
>
|
||||
<ScrollArea className="h-full">
|
||||
<div className="p-4 space-y-6">
|
||||
{/* Provider Information */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium flex items-center gap-2 mb-3">
|
||||
<Info className="w-4 h-4" />
|
||||
Provider Information
|
||||
</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Provider</span>
|
||||
<span className="font-mono">{displayName}</span>
|
||||
</div>
|
||||
{data && (
|
||||
<>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">File Path</span>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs break-all">
|
||||
{data.path}
|
||||
</code>
|
||||
<CopyButton value={data.path} size="icon" className="h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Last Modified</span>
|
||||
<span className="text-xs">{new Date(data.mtime).toLocaleString()}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-center">
|
||||
<span className="font-medium text-muted-foreground">Status</span>
|
||||
{authStatus.authenticated ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="w-fit text-green-600 border-green-200 bg-green-50"
|
||||
>
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Authenticated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="w-fit text-muted-foreground">
|
||||
Not connected
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Usage */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Quick Usage</h3>
|
||||
<div className="space-y-3 bg-card rounded-lg border p-4 shadow-sm">
|
||||
<UsageCommand
|
||||
label="Run with prompt"
|
||||
command={`ccs ${provider} "your prompt"`}
|
||||
/>
|
||||
<UsageCommand label="Change model" command={`ccs ${provider} --config`} />
|
||||
<UsageCommand label="Add account" command={`ccs ${provider} --add`} />
|
||||
<UsageCommand label="List accounts" command={`ccs ${provider} --accounts`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Render Right Column - Raw JSON Editor
|
||||
const renderRawEditor = () => (
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">Loading editor...</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="h-full flex flex-col">
|
||||
{!isRawJsonValid && rawJsonEdits !== null && (
|
||||
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
|
||||
<X className="w-4 h-4" />
|
||||
Invalid JSON syntax
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-hidden px-6 pb-6 pt-4">
|
||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||
<CodeEditor
|
||||
value={rawJsonContent}
|
||||
onChange={handleRawJsonChange}
|
||||
language="json"
|
||||
minHeight="100%"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b bg-background flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<ProviderLogo provider={logoProvider || provider} size="lg" />
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">{displayName}</h2>
|
||||
{data && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{data.path.replace(/^.*\//, '')}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{data && (
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Last modified: {new Date(data.mtime).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={() => refetch()} disabled={isLoading}>
|
||||
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => saveMutation.mutate()}
|
||||
disabled={saveMutation.isPending || !hasChanges || !isRawJsonValid}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="w-4 h-4 mr-1" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
<span className="ml-3 text-muted-foreground">Loading settings...</span>
|
||||
</div>
|
||||
) : (
|
||||
// Split Layout (40% Left / 60% Right)
|
||||
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||
{/* Left Column: Friendly UI */}
|
||||
<div className="flex flex-col overflow-hidden bg-muted/5">{renderFriendlyUI()}</div>
|
||||
|
||||
{/* Right Column: Raw Editor */}
|
||||
<div className="flex flex-col overflow-hidden">
|
||||
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
|
||||
<Code2 className="w-4 h-4 text-muted-foreground" />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
Raw Configuration (JSON)
|
||||
</span>
|
||||
</div>
|
||||
{renderRawEditor()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={conflictDialog}
|
||||
title="File Modified Externally"
|
||||
description="This settings file was modified by another process. Overwrite with your changes or discard?"
|
||||
confirmText="Overwrite"
|
||||
variant="destructive"
|
||||
onConfirm={() => handleConflictResolve(true)}
|
||||
onCancel={() => handleConflictResolve(false)}
|
||||
/>
|
||||
|
||||
{/* Custom Preset Dialog */}
|
||||
<CustomPresetDialog
|
||||
open={customPresetOpen}
|
||||
onClose={() => setCustomPresetOpen(false)}
|
||||
currentValues={{
|
||||
default: currentModel || '',
|
||||
opus: opusModel || '',
|
||||
sonnet: sonnetModel || '',
|
||||
haiku: haikuModel || '',
|
||||
}}
|
||||
onApply={(values, presetName) => {
|
||||
updateEnvValues({
|
||||
ANTHROPIC_MODEL: values.default,
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: values.sonnet,
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: values.haiku,
|
||||
});
|
||||
toast.success(`Applied ${presetName ? `"${presetName}"` : 'custom'} preset`);
|
||||
setCustomPresetOpen(false);
|
||||
}}
|
||||
onSave={(values, presetName) => {
|
||||
if (!presetName) {
|
||||
toast.error('Please enter a preset name to save');
|
||||
return;
|
||||
}
|
||||
createPresetMutation.mutate({
|
||||
profile: provider,
|
||||
data: {
|
||||
name: presetName,
|
||||
default: values.default,
|
||||
opus: values.opus,
|
||||
sonnet: values.sonnet,
|
||||
haiku: values.haiku,
|
||||
},
|
||||
});
|
||||
setCustomPresetOpen(false);
|
||||
}}
|
||||
isSaving={createPresetMutation.isPending}
|
||||
catalog={catalog}
|
||||
allModels={providerModels}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Account item component */
|
||||
function AccountItem({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
}: {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
isRemoving?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg border transition-colors',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{account.email || account.id}</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Usage command with copy button */
|
||||
function UsageCommand({ label, command }: { label: string; command: string }) {
|
||||
return (
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground">{label}</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<code className="flex-1 px-2 py-1.5 bg-muted rounded text-xs font-mono truncate">
|
||||
{command}
|
||||
</code>
|
||||
<CopyButton value={command} size="icon" className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Custom Preset Dialog - Configure all model mappings at once */
|
||||
interface CustomPresetDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
currentValues: {
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
onApply: (
|
||||
values: { default: string; opus: string; sonnet: string; haiku: string },
|
||||
presetName?: string
|
||||
) => void;
|
||||
onSave?: (
|
||||
values: { default: string; opus: string; sonnet: string; haiku: string },
|
||||
presetName?: string
|
||||
) => void;
|
||||
isSaving?: boolean;
|
||||
catalog?: ProviderCatalog;
|
||||
allModels: { id: string; owned_by: string }[];
|
||||
}
|
||||
|
||||
function CustomPresetDialog({
|
||||
open,
|
||||
onClose,
|
||||
currentValues,
|
||||
onApply,
|
||||
onSave,
|
||||
isSaving,
|
||||
catalog,
|
||||
allModels,
|
||||
}: CustomPresetDialogProps) {
|
||||
const [values, setValues] = useState(currentValues);
|
||||
const [presetName, setPresetName] = useState('');
|
||||
|
||||
// Reset values when dialog opens with current values
|
||||
const handleOpenChange = (isOpen: boolean) => {
|
||||
if (isOpen) {
|
||||
setValues(currentValues);
|
||||
setPresetName('');
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Custom Preset
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="preset-name">Preset Name (optional)</Label>
|
||||
<Input
|
||||
id="preset-name"
|
||||
value={presetName}
|
||||
onChange={(e) => setPresetName(e.target.value)}
|
||||
placeholder="e.g., My Custom Config"
|
||||
className="text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Separator />
|
||||
<FlexibleModelSelector
|
||||
label="Default Model"
|
||||
description="Used when no specific tier is requested"
|
||||
value={values.default}
|
||||
onChange={(model) => setValues({ ...values, default: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Opus (Most capable)"
|
||||
description="For complex reasoning tasks"
|
||||
value={values.opus}
|
||||
onChange={(model) => setValues({ ...values, opus: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet (Balanced)"
|
||||
description="Balance of speed and capability"
|
||||
value={values.sonnet}
|
||||
onChange={(model) => setValues({ ...values, sonnet: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
<FlexibleModelSelector
|
||||
label="Haiku (Fast)"
|
||||
description="Quick responses for simple tasks"
|
||||
value={values.haiku}
|
||||
onChange={(model) => setValues({ ...values, haiku: model })}
|
||||
catalog={catalog}
|
||||
allModels={allModels}
|
||||
/>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{onSave && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => onSave(values, presetName || undefined)}
|
||||
disabled={isSaving || !presetName.trim()}
|
||||
>
|
||||
{isSaving ? (
|
||||
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Star className="w-4 h-4 mr-1" />
|
||||
)}
|
||||
Save Preset
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => onApply(values, presetName || undefined)}>
|
||||
<Zap className="w-4 h-4 mr-1" />
|
||||
Apply Preset
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Provider Logo Component
|
||||
* Uses actual provider logos with fallback to styled letters
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ProviderLogoProps {
|
||||
provider: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
/** Provider image assets mapping */
|
||||
const PROVIDER_IMAGES: Record<string, string> = {
|
||||
gemini: '/assets/providers/gemini-color.svg',
|
||||
codex: '/assets/providers/openai.svg',
|
||||
agy: '/assets/providers/agy.png',
|
||||
qwen: '/assets/providers/qwen-color.svg',
|
||||
};
|
||||
|
||||
/** Provider color configuration (for fallback only - no background for image logos) */
|
||||
const PROVIDER_CONFIG: Record<string, { text: string; letter: string }> = {
|
||||
gemini: { text: 'text-blue-600', letter: 'G' },
|
||||
claude: { text: 'text-orange-600', letter: 'C' },
|
||||
codex: { text: 'text-emerald-600', letter: 'X' },
|
||||
agy: { text: 'text-violet-600', letter: 'A' },
|
||||
qwen: { text: 'text-cyan-600', letter: 'Q' },
|
||||
iflow: { text: 'text-indigo-600', letter: 'i' },
|
||||
};
|
||||
|
||||
/** Size configuration */
|
||||
const SIZE_CONFIG = {
|
||||
sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' },
|
||||
md: { container: 'w-8 h-8', icon: 'w-5 h-5', text: 'text-sm' },
|
||||
lg: { container: 'w-12 h-12', icon: 'w-8 h-8', text: 'text-lg' },
|
||||
};
|
||||
|
||||
export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) {
|
||||
const providerKey = provider.toLowerCase();
|
||||
const config = PROVIDER_CONFIG[providerKey] || {
|
||||
text: 'text-gray-600',
|
||||
letter: provider[0]?.toUpperCase() || '?',
|
||||
};
|
||||
const sizeConfig = SIZE_CONFIG[size];
|
||||
const imageSrc = PROVIDER_IMAGES[providerKey];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center rounded-md',
|
||||
imageSrc && 'bg-white p-1',
|
||||
sizeConfig.container,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{imageSrc ? (
|
||||
<img
|
||||
src={imageSrc}
|
||||
alt={`${provider} logo`}
|
||||
className={cn(sizeConfig.icon, 'object-contain')}
|
||||
/>
|
||||
) : (
|
||||
<span className={cn('font-semibold', config.text, sizeConfig.text)}>{config.letter}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Inline variant for use in text */
|
||||
export function ProviderLogoInline({
|
||||
provider,
|
||||
className,
|
||||
}: {
|
||||
provider: string;
|
||||
className?: string;
|
||||
}) {
|
||||
return <ProviderLogo provider={provider} size="sm" className={className} />;
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/**
|
||||
* Provider Model Selector Component
|
||||
* Per-provider model selection using model-catalog.ts
|
||||
* Includes tier badges, broken/deprecated status indicators
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { AlertTriangle, AlertCircle, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** Model entry from catalog */
|
||||
export interface ModelEntry {
|
||||
id: string;
|
||||
name: string;
|
||||
tier?: 'free' | 'paid';
|
||||
description?: string;
|
||||
broken?: boolean;
|
||||
issueUrl?: string;
|
||||
deprecated?: boolean;
|
||||
deprecationReason?: string;
|
||||
/** Optional preset mapping for different tiers (if different from id) */
|
||||
presetMapping?: {
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
};
|
||||
}
|
||||
|
||||
/** Provider catalog */
|
||||
export interface ProviderCatalog {
|
||||
provider: string;
|
||||
displayName: string;
|
||||
models: ModelEntry[];
|
||||
defaultModel: string;
|
||||
}
|
||||
|
||||
interface ProviderModelSelectorProps {
|
||||
/** Provider catalog data */
|
||||
catalog: ProviderCatalog | undefined;
|
||||
/** Loading state */
|
||||
isLoading?: boolean;
|
||||
/** Currently selected model */
|
||||
value: string | undefined;
|
||||
/** Callback when model changes */
|
||||
onChange: (model: string) => void;
|
||||
/** Disabled state */
|
||||
disabled?: boolean;
|
||||
/** Placeholder text */
|
||||
placeholder?: string;
|
||||
/** Custom className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ProviderModelSelector({
|
||||
catalog,
|
||||
isLoading,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder = 'Select a model',
|
||||
className,
|
||||
}: ProviderModelSelectorProps) {
|
||||
// Group models by tier
|
||||
const groupedModels = useMemo(() => {
|
||||
if (!catalog?.models) return { free: [], paid: [] };
|
||||
return {
|
||||
free: catalog.models.filter((m) => !m.tier || m.tier === 'free'),
|
||||
paid: catalog.models.filter((m) => m.tier === 'paid'),
|
||||
};
|
||||
}, [catalog]);
|
||||
|
||||
const selectedModel = useMemo(() => {
|
||||
return catalog?.models.find((m) => m.id === value);
|
||||
}, [catalog, value]);
|
||||
|
||||
if (isLoading) {
|
||||
return <Skeleton className={cn('h-9 w-full', className)} />;
|
||||
}
|
||||
|
||||
if (!catalog || catalog.models.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground py-2', className)}>
|
||||
No models available for this provider
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const renderModelItem = (model: ModelEntry) => (
|
||||
<SelectItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
className={cn('pl-4', model.broken && 'opacity-60', model.deprecated && 'opacity-60')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{model.name}</span>
|
||||
{model.broken && (
|
||||
<Badge variant="destructive" className="text-[9px] h-4 px-1">
|
||||
BROKEN
|
||||
</Badge>
|
||||
)}
|
||||
{model.deprecated && (
|
||||
<Badge variant="secondary" className="text-[9px] h-4 px-1">
|
||||
DEPRECATED
|
||||
</Badge>
|
||||
)}
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder={placeholder}>
|
||||
{selectedModel && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate">{selectedModel.name}</span>
|
||||
{selectedModel.tier === 'paid' && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
PAID
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{groupedModels.free.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">Free Tier</SelectLabel>
|
||||
{groupedModels.free.map(renderModelItem)}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{groupedModels.paid.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-amber-600">Paid Tier</SelectLabel>
|
||||
{groupedModels.paid.map(renderModelItem)}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Warning for broken/deprecated models */}
|
||||
{selectedModel?.broken && (
|
||||
<div className="flex items-start gap-2 p-2 bg-destructive/10 rounded-md text-xs text-destructive">
|
||||
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">This model has known issues</p>
|
||||
{selectedModel.issueUrl && (
|
||||
<a
|
||||
href={selectedModel.issueUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="underline hover:no-underline"
|
||||
>
|
||||
View issue details
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedModel?.deprecated && (
|
||||
<div className="flex items-start gap-2 p-2 bg-amber-500/10 rounded-md text-xs text-amber-700">
|
||||
<AlertCircle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p className="font-medium">This model is deprecated</p>
|
||||
{selectedModel.deprecationReason && (
|
||||
<p className="opacity-80">{selectedModel.deprecationReason}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model description */}
|
||||
{selectedModel?.description && !selectedModel.broken && !selectedModel.deprecated && (
|
||||
<p className="text-xs text-muted-foreground">{selectedModel.description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Model Mapping Selector - For Opus/Sonnet/Haiku mapping */
|
||||
interface ModelMappingSelectorProps {
|
||||
catalog: ProviderCatalog | undefined;
|
||||
label: string;
|
||||
value: string | undefined;
|
||||
onChange: (model: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function ModelMappingSelector({
|
||||
catalog,
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
}: ModelMappingSelectorProps) {
|
||||
if (!catalog) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">{label}</label>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{catalog.models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm">{model.name}</span>
|
||||
{model.tier === 'paid' && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
PAID
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Flexible Model Selector - Combines catalog recommendations with full model list */
|
||||
interface FlexibleModelSelectorProps {
|
||||
label: string;
|
||||
description?: string;
|
||||
value: string | undefined;
|
||||
onChange: (model: string) => void;
|
||||
catalog?: ProviderCatalog;
|
||||
allModels: { id: string; owned_by: string }[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function FlexibleModelSelector({
|
||||
label,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
catalog,
|
||||
allModels,
|
||||
disabled,
|
||||
}: FlexibleModelSelectorProps) {
|
||||
// Combine catalog models (recommended) with all available models
|
||||
const catalogModelIds = new Set(catalog?.models.map((m) => m.id) || []);
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<div>
|
||||
<label className="text-xs font-medium">{label}</label>
|
||||
{description && <p className="text-[10px] text-muted-foreground">{description}</p>}
|
||||
</div>
|
||||
<Select value={value || ''} onValueChange={onChange} disabled={disabled}>
|
||||
<SelectTrigger className="h-9">
|
||||
<SelectValue placeholder="Select model">
|
||||
{value && <span className="truncate font-mono text-xs">{value}</span>}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[300px]">
|
||||
{/* Recommended models from catalog */}
|
||||
{catalog && catalog.models.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-primary">Recommended</SelectLabel>
|
||||
{catalog.models.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
{model.tier === 'paid' && (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
PAID
|
||||
</Badge>
|
||||
)}
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* All available models (excluding already shown) */}
|
||||
{allModels.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel className="text-xs text-muted-foreground">
|
||||
All Models ({allModels.length})
|
||||
</SelectLabel>
|
||||
{allModels
|
||||
.filter((m) => !catalogModelIds.has(m.id))
|
||||
.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{model.id}</span>
|
||||
{value === model.id && <Check className="w-3 h-3 text-primary ml-auto" />}
|
||||
</div>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
|
||||
{/* Fallback if no models available */}
|
||||
{(!catalog || catalog.models.length === 0) && allModels.length === 0 && (
|
||||
<div className="py-2 px-3 text-xs text-muted-foreground">No models available</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -72,3 +72,43 @@ export function useCliproxyStats(enabled = true) {
|
||||
staleTime: 10000, // Consider data stale after 10 seconds
|
||||
});
|
||||
}
|
||||
|
||||
/** CLIProxy model from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
/** Categorized models response */
|
||||
export interface CliproxyModelsResponse {
|
||||
models: CliproxyModel[];
|
||||
byCategory: Record<string, CliproxyModel[]>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch CLIProxy models from API
|
||||
*/
|
||||
async function fetchCliproxyModels(): Promise<CliproxyModelsResponse> {
|
||||
const response = await fetch('/api/cliproxy/models');
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.message || 'Failed to fetch models');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get available CLIProxy models (categorized by provider)
|
||||
*/
|
||||
export function useCliproxyModels(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ['cliproxy-models'],
|
||||
queryFn: fetchCliproxyModels,
|
||||
enabled,
|
||||
staleTime: 60000, // Models don't change often, cache for 1 minute
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api, type CreateVariant, type UpdateVariant } from '@/lib/api-client';
|
||||
import { api, type CreateVariant, type UpdateVariant, type CreatePreset } from '@/lib/api-client';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export function useCliproxy() {
|
||||
@@ -149,3 +149,45 @@ export function useUpdateModel() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== Presets ====================
|
||||
|
||||
export function usePresets(profile: string) {
|
||||
return useQuery({
|
||||
queryKey: ['presets', profile],
|
||||
queryFn: () => api.presets.list(profile),
|
||||
enabled: !!profile,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ profile, data }: { profile: string; data: CreatePreset }) =>
|
||||
api.presets.create(profile, data),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] });
|
||||
toast.success(`Preset "${variables.data.name}" saved`);
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePreset() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ profile, name }: { profile: string; name: string }) =>
|
||||
api.presets.delete(profile, name),
|
||||
onSuccess: (_data, variables) => {
|
||||
queryClient.invalidateQueries({ queryKey: ['presets', variables.profile] });
|
||||
toast.success('Preset deleted');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -92,6 +92,21 @@ export interface AuthFile {
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
/** CLIProxy model from /v1/models endpoint */
|
||||
export interface CliproxyModel {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
owned_by: string;
|
||||
}
|
||||
|
||||
/** Categorized models response from CLIProxyAPI */
|
||||
export interface CliproxyModelsResponse {
|
||||
models: CliproxyModel[];
|
||||
byCategory: Record<string, CliproxyModel[]>;
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
/** Provider accounts summary */
|
||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||
|
||||
@@ -122,6 +137,23 @@ export interface SecretsExists {
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
/** Model preset for quick model switching */
|
||||
export interface ModelPreset {
|
||||
name: string;
|
||||
default: string;
|
||||
opus: string;
|
||||
sonnet: string;
|
||||
haiku: string;
|
||||
}
|
||||
|
||||
export interface CreatePreset {
|
||||
name: string;
|
||||
default: string;
|
||||
opus?: string;
|
||||
sonnet?: string;
|
||||
haiku?: string;
|
||||
}
|
||||
|
||||
// API
|
||||
export const api = {
|
||||
profiles: {
|
||||
@@ -155,10 +187,7 @@ export const api = {
|
||||
|
||||
// Stats and models for Overview tab
|
||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||
models: () =>
|
||||
request<{
|
||||
providers: Record<string, { currentModel: string; availableModels: string[] }>;
|
||||
}>('/cliproxy/models'),
|
||||
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||
updateModel: (provider: string, model: string) =>
|
||||
request(`/cliproxy/models/${provider}`, {
|
||||
method: 'PUT',
|
||||
@@ -240,4 +269,17 @@ export const api = {
|
||||
}),
|
||||
exists: (profile: string) => request<SecretsExists>(`/secrets/${profile}/exists`),
|
||||
},
|
||||
/** Model presets for quick model switching */
|
||||
presets: {
|
||||
list: (profile: string) => request<{ presets: ModelPreset[] }>(`/settings/${profile}/presets`),
|
||||
create: (profile: string, data: CreatePreset) =>
|
||||
request<{ preset: ModelPreset }>(`/settings/${profile}/presets`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
delete: (profile: string, name: string) =>
|
||||
request<{ success: boolean }>(`/settings/${profile}/presets/${encodeURIComponent(name)}`, {
|
||||
method: 'DELETE',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -11,7 +11,7 @@ import { startOfMonth, subDays, formatDistanceToNow } from 'date-fns';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Popover, PopoverContent, PopoverTrigger, PopoverAnchor } from '@/components/ui/popover';
|
||||
import { Popover, PopoverContent, PopoverAnchor } from '@/components/ui/popover';
|
||||
import { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
||||
@@ -19,16 +19,13 @@ import { ModelBreakdownChart } from '@/components/analytics/model-breakdown-char
|
||||
import { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTrends,
|
||||
useModelUsage,
|
||||
useRefreshUsage,
|
||||
useUsageStatus,
|
||||
useUsageInsights,
|
||||
useSessions,
|
||||
type ModelUsage,
|
||||
} from '@/hooks/use-usage';
|
||||
@@ -75,7 +72,6 @@ export function AnalyticsPage() {
|
||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(apiOptions);
|
||||
const { data: insights, isLoading: isInsightsLoading } = useUsageInsights(apiOptions);
|
||||
const { data: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||
const { data: status } = useUsageStatus();
|
||||
|
||||
@@ -117,40 +113,6 @@ export function AnalyticsPage() {
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* Usage Insights Card (replaces popover) */}
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="gap-1.5 h-8" title="Usage Insights">
|
||||
<Lightbulb
|
||||
className={`w-3.5 h-3.5 ${
|
||||
insights?.summary?.totalAnomalies ? 'text-amber-500' : 'text-green-500'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs">Insights</span>
|
||||
{insights?.summary?.totalAnomalies ? (
|
||||
<Badge variant="destructive" className="h-4 px-1 text-[10px] font-bold ml-0.5">
|
||||
{insights.summary.totalAnomalies}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 px-1 text-[10px] font-bold ml-0.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||
>
|
||||
OK
|
||||
</Badge>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-96 p-0 border-0 shadow-lg" align="end">
|
||||
<UsageInsightsCard
|
||||
anomalies={insights?.anomalies}
|
||||
summary={insights?.summary}
|
||||
isLoading={isInsightsLoading}
|
||||
className="border-0 shadow-none max-h-[400px]"
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{lastUpdatedText && (
|
||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||
Updated {lastUpdatedText}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* CLIProxy Control Panel Page
|
||||
*
|
||||
* Dedicated page for the CLIProxy management panel.
|
||||
*/
|
||||
|
||||
import { ControlPanelEmbed } from '@/components/cliproxy/control-panel-embed';
|
||||
|
||||
export function CliproxyControlPanelPage() {
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex flex-col">
|
||||
<ControlPanelEmbed />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+281
-357
@@ -1,92 +1,142 @@
|
||||
/**
|
||||
* CLIProxy Page - Master-Detail Layout
|
||||
* Left sidebar: Provider navigation + Quick actions
|
||||
* Right panel: Provider details, accounts, preferences
|
||||
* Right panel: Provider Editor with split-view (settings + code editor)
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Check,
|
||||
X,
|
||||
User,
|
||||
Star,
|
||||
Trash2,
|
||||
Sparkles,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
FileText,
|
||||
Terminal,
|
||||
Zap,
|
||||
Shield,
|
||||
Clock,
|
||||
MoreHorizontal,
|
||||
} from 'lucide-react';
|
||||
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
|
||||
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||
import { AddAccountDialog } from '@/components/add-account-dialog';
|
||||
import { ConfigSplitView } from '@/components/cliproxy/config/config-split-view';
|
||||
import { LogViewer } from '@/components/cliproxy/logs/log-viewer';
|
||||
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
|
||||
import {
|
||||
useCliproxy,
|
||||
useCliproxyAuth,
|
||||
useSetDefaultAccount,
|
||||
useRemoveAccount,
|
||||
useCliproxyModels,
|
||||
useUpdateModel,
|
||||
useDeleteVariant,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
||||
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
|
||||
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type ViewMode = 'overview' | 'config' | 'logs';
|
||||
|
||||
// Provider icon component
|
||||
function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
|
||||
const iconMap: Record<string, { bg: string; text: string; letter: string }> = {
|
||||
gemini: { bg: 'bg-blue-500/10', text: 'text-blue-600', letter: 'G' },
|
||||
claude: { bg: 'bg-orange-500/10', text: 'text-orange-600', letter: 'C' },
|
||||
codex: { bg: 'bg-green-500/10', text: 'text-green-600', letter: 'X' },
|
||||
agy: { bg: 'bg-purple-500/10', text: 'text-purple-600', letter: 'A' },
|
||||
};
|
||||
const config = iconMap[provider.toLowerCase()] || {
|
||||
bg: 'bg-gray-500/10',
|
||||
text: 'text-gray-600',
|
||||
letter: provider[0]?.toUpperCase() || '?',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-lg font-semibold text-sm',
|
||||
config.bg,
|
||||
config.text,
|
||||
className
|
||||
)}
|
||||
>
|
||||
{config.letter}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// Model catalog data - mirrors src/cliproxy/model-catalog.ts
|
||||
const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
agy: {
|
||||
provider: 'agy',
|
||||
displayName: 'Antigravity',
|
||||
defaultModel: 'gemini-claude-opus-4-5-thinking',
|
||||
models: [
|
||||
{
|
||||
id: 'gemini-claude-opus-4-5-thinking',
|
||||
name: 'Claude Opus 4.5 Thinking',
|
||||
description: 'Most capable, extended thinking',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5-thinking',
|
||||
name: 'Claude Sonnet 4.5 Thinking',
|
||||
description: 'Balanced with extended thinking',
|
||||
},
|
||||
{
|
||||
id: 'gemini-claude-sonnet-4-5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
description: 'Fast and capable',
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
name: 'Gemini 3 Pro',
|
||||
description: 'Google latest model via Antigravity',
|
||||
},
|
||||
],
|
||||
},
|
||||
gemini: {
|
||||
provider: 'gemini',
|
||||
displayName: 'Gemini',
|
||||
defaultModel: 'gemini-2.5-pro',
|
||||
models: [
|
||||
{
|
||||
id: 'gemini-3-pro-preview',
|
||||
name: 'Gemini 3 Pro',
|
||||
tier: 'paid',
|
||||
description: 'Latest model, requires paid Google account',
|
||||
},
|
||||
{
|
||||
id: 'gemini-2.5-pro',
|
||||
name: 'Gemini 2.5 Pro',
|
||||
description: 'Stable, works with free Google account',
|
||||
},
|
||||
],
|
||||
},
|
||||
codex: {
|
||||
provider: 'codex',
|
||||
displayName: 'Codex',
|
||||
defaultModel: 'gpt-5.1-codex-max',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.1-codex-max',
|
||||
name: 'Codex Max (5.1)',
|
||||
description: 'Most capable Codex model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.1-codex-max',
|
||||
opus: 'gpt-5.1-codex-max-high',
|
||||
sonnet: 'gpt-5.1-codex-max',
|
||||
haiku: 'gpt-5.1-codex-mini-high',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT 5.2',
|
||||
description: 'Latest GPT model',
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2',
|
||||
opus: 'gpt-5.2',
|
||||
sonnet: 'gpt-5.2',
|
||||
haiku: 'gpt-5.2',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1-codex-mini',
|
||||
name: 'Codex Mini',
|
||||
description: 'Fast and efficient Codex model',
|
||||
},
|
||||
],
|
||||
},
|
||||
qwen: {
|
||||
provider: 'qwen',
|
||||
displayName: 'Qwen',
|
||||
defaultModel: 'qwen-coder-plus',
|
||||
models: [
|
||||
{
|
||||
id: 'qwen-coder-plus',
|
||||
name: 'Qwen Coder Plus',
|
||||
description: 'Alibaba code-focused model',
|
||||
},
|
||||
{
|
||||
id: 'qwen-max',
|
||||
name: 'Qwen Max',
|
||||
description: 'Most capable Qwen model',
|
||||
},
|
||||
],
|
||||
},
|
||||
iflow: {
|
||||
provider: 'iflow',
|
||||
displayName: 'iFlow',
|
||||
defaultModel: 'iflow-default',
|
||||
models: [
|
||||
{
|
||||
id: 'iflow-default',
|
||||
name: 'iFlow Default',
|
||||
description: 'Default iFlow model',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Sidebar provider item
|
||||
function ProviderSidebarItem({
|
||||
@@ -110,7 +160,7 @@ function ProviderSidebarItem({
|
||||
)}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<ProviderIcon provider={status.provider} />
|
||||
<ProviderLogo provider={status.provider} size="md" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{status.displayName}</span>
|
||||
@@ -138,259 +188,70 @@ function ProviderSidebarItem({
|
||||
);
|
||||
}
|
||||
|
||||
// Quick action item in sidebar
|
||||
function QuickActionItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
onClick,
|
||||
// Sidebar variant item (user-created provider variants)
|
||||
function VariantSidebarItem({
|
||||
variant,
|
||||
parentAuth,
|
||||
isSelected,
|
||||
onSelect,
|
||||
onDelete,
|
||||
isDeleting,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
onClick: () => void;
|
||||
variant: Variant;
|
||||
parentAuth?: AuthStatus;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
isDeleting?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left text-sm',
|
||||
isActive ? 'bg-muted font-medium' : 'hover:bg-muted/50 text-muted-foreground'
|
||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
|
||||
isSelected
|
||||
? 'bg-primary/10 border border-primary/20'
|
||||
: 'hover:bg-muted border border-transparent'
|
||||
)}
|
||||
onClick={onClick}
|
||||
onClick={onSelect}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Account badge with actions
|
||||
function AccountBadge({
|
||||
account,
|
||||
onSetDefault,
|
||||
onRemove,
|
||||
isRemoving,
|
||||
}: {
|
||||
account: OAuthAccount;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
isRemoving: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-between p-3 rounded-lg border transition-colors',
|
||||
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center justify-center w-8 h-8 rounded-full',
|
||||
account.isDefault ? 'bg-primary/10' : 'bg-muted'
|
||||
)}
|
||||
>
|
||||
<User className="w-4 h-4" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm">{account.email || account.id}</span>
|
||||
{account.isDefault && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5 gap-0.5">
|
||||
<Star className="w-2.5 h-2.5 fill-current" />
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{account.lastUsedAt && (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground mt-0.5">
|
||||
<Clock className="w-3 h-3" />
|
||||
Last used: {new Date(account.lastUsedAt).toLocaleDateString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<ProviderLogo provider={variant.provider} size="sm" />
|
||||
<GitBranch className="w-2.5 h-2.5 absolute -bottom-0.5 -right-0.5 text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
||||
<MoreHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{!account.isDefault && (
|
||||
<DropdownMenuItem onClick={onSetDefault}>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Set as default
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={onRemove}
|
||||
disabled={isRemoving}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{isRemoving ? 'Removing...' : 'Remove account'}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Provider detail panel
|
||||
function ProviderDetailPanel({
|
||||
status,
|
||||
onAddAccount,
|
||||
}: {
|
||||
status: AuthStatus;
|
||||
onAddAccount: () => void;
|
||||
}) {
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
const { data: modelsData } = useCliproxyModels();
|
||||
const updateModelMutation = useUpdateModel();
|
||||
const { data: statsData } = useCliproxyStats();
|
||||
|
||||
const accounts = status.accounts || [];
|
||||
const providerData = modelsData?.providers?.[status.provider];
|
||||
const providerModels = providerData?.availableModels || [];
|
||||
const currentModel = providerData?.currentModel;
|
||||
|
||||
// Get stats for this provider
|
||||
const providerRequestCount = useMemo(() => {
|
||||
if (!statsData?.requestsByProvider) return null;
|
||||
return statsData.requestsByProvider[status.provider] ?? null;
|
||||
}, [statsData, status.provider]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col min-w-0 p-6 overflow-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<ProviderIcon provider={status.provider} className="w-12 h-12 text-lg" />
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold">{status.displayName}</h2>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
{status.authenticated ? (
|
||||
<Badge variant="outline" className="text-green-600 border-green-200 bg-green-50">
|
||||
<Shield className="w-3 h-3 mr-1" />
|
||||
Authenticated
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" className="text-muted-foreground">
|
||||
<X className="w-3 h-3 mr-1" />
|
||||
Not connected
|
||||
</Badge>
|
||||
)}
|
||||
{providerRequestCount !== null && providerRequestCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Zap className="w-3 h-3 mr-1" />
|
||||
{providerRequestCount.toLocaleString()} requests
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm truncate">{variant.name}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
variant
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Button onClick={onAddAccount} className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Model Preference */}
|
||||
<Card className="mb-4">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
Model Preference
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
value={currentModel || ''}
|
||||
onValueChange={(value) => {
|
||||
updateModelMutation.mutate({ provider: status.provider, model: value });
|
||||
}}
|
||||
disabled={updateModelMutation.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[280px]">
|
||||
<SelectValue placeholder="Select preferred model" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerModels.map((model: string) => (
|
||||
<SelectItem key={model} value={model}>
|
||||
{model}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{updateModelMutation.isPending && (
|
||||
<span className="text-xs text-muted-foreground">Updating...</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Accounts */}
|
||||
<Card className="flex-1">
|
||||
<CardHeader className="py-3 px-4">
|
||||
<CardTitle className="text-sm font-medium flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4" />
|
||||
Accounts
|
||||
{accounts.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{accounts.length}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-4 pt-0">
|
||||
{accounts.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{accounts.map((account) => (
|
||||
<AccountBadge
|
||||
key={account.id}
|
||||
account={account}
|
||||
onSetDefault={() =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: status.provider,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
onRemove={() =>
|
||||
removeMutation.mutate({
|
||||
provider: status.provider,
|
||||
accountId: account.id,
|
||||
})
|
||||
}
|
||||
isRemoving={removeMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
{parentAuth?.authenticated ? (
|
||||
<>
|
||||
<Check className="w-3 h-3 text-green-600" />
|
||||
<span className="text-xs text-muted-foreground truncate">via {variant.provider}</span>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||
<User className="w-6 h-6 text-muted-foreground" />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mb-3">
|
||||
{status.authenticated
|
||||
? 'No specific accounts tracked'
|
||||
: 'Connect an account to get started'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={onAddAccount} className="gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Account
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
<X className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-muted-foreground">Parent not connected</span>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 opacity-0 group-hover:opacity-100 hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
}}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</Button>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -402,10 +263,17 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center mx-auto mb-6">
|
||||
<Zap className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold mb-2">CLIProxy Manager</h2>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Manage OAuth authentication for Claude CLI proxy providers. Select a provider from the
|
||||
sidebar or run the quick setup wizard.
|
||||
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Manage OAuth authentication, account preferences, and model selection for CLIProxy
|
||||
providers. Configure how CCS routes requests to different AI backends.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mb-6">
|
||||
For live usage stats and real-time monitoring, visit the{' '}
|
||||
<a href="/cliproxy/control-panel" className="text-primary hover:underline">
|
||||
Control Panel
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
<Button onClick={onSetup} className="gap-2">
|
||||
<Sparkles className="w-4 h-4" />
|
||||
@@ -419,39 +287,61 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
||||
export function CliproxyPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
||||
const { isFetching } = useCliproxy();
|
||||
const { data: variantsData, isFetching } = useCliproxy();
|
||||
const setDefaultMutation = useSetDefaultAccount();
|
||||
const removeMutation = useRemoveAccount();
|
||||
const deleteMutation = useDeleteVariant();
|
||||
|
||||
// Selection state: either a provider or a variant
|
||||
const [selectedProvider, setSelectedProvider] = useState<string | null>(null);
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('overview');
|
||||
const [selectedVariant, setSelectedVariant] = useState<string | null>(null);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
const [addAccountProvider, setAddAccountProvider] = useState<{
|
||||
provider: string;
|
||||
displayName: string;
|
||||
} | null>(null);
|
||||
|
||||
// Auto-select first provider if none selected
|
||||
const providers = authData?.authStatus || [];
|
||||
const variants = variantsData?.variants || [];
|
||||
|
||||
// Auto-select first provider if nothing selected
|
||||
const effectiveProvider = useMemo(() => {
|
||||
// If a variant is selected, no provider is effective
|
||||
if (selectedVariant) return null;
|
||||
if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) {
|
||||
return selectedProvider;
|
||||
}
|
||||
return providers.length > 0 ? providers[0].provider : null;
|
||||
}, [selectedProvider, providers]);
|
||||
}, [selectedProvider, selectedVariant, providers]);
|
||||
|
||||
const selectedStatus = providers.find((p) => p.provider === effectiveProvider);
|
||||
const selectedVariantData = variants.find((v) => v.name === selectedVariant);
|
||||
const parentAuthForVariant = selectedVariantData
|
||||
? providers.find((p) => p.provider === selectedVariantData.provider)
|
||||
: undefined;
|
||||
|
||||
const handleRefresh = () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||
};
|
||||
|
||||
const handleSelectProvider = (provider: string) => {
|
||||
setSelectedProvider(provider);
|
||||
setSelectedVariant(null);
|
||||
};
|
||||
|
||||
const handleSelectVariant = (variantName: string) => {
|
||||
setSelectedVariant(variantName);
|
||||
setSelectedProvider(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-100px)] flex">
|
||||
{/* Left Sidebar */}
|
||||
<div className="w-64 border-r flex flex-col bg-muted/30">
|
||||
{/* Header */}
|
||||
<div className="p-4 border-b bg-background">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="w-5 h-5 text-primary" />
|
||||
<h1 className="font-semibold">CLIProxy</h1>
|
||||
@@ -466,6 +356,7 @@ export function CliproxyPage() {
|
||||
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -496,38 +387,35 @@ export function CliproxyPage() {
|
||||
<ProviderSidebarItem
|
||||
key={status.provider}
|
||||
status={status}
|
||||
isSelected={effectiveProvider === status.provider && viewMode === 'overview'}
|
||||
onSelect={() => {
|
||||
setSelectedProvider(status.provider);
|
||||
setViewMode('overview');
|
||||
}}
|
||||
isSelected={effectiveProvider === status.provider}
|
||||
onSelect={() => handleSelectProvider(status.provider)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="p-2">
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
|
||||
Tools
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<QuickActionItem
|
||||
icon={FileText}
|
||||
label="Config Editor"
|
||||
isActive={viewMode === 'config'}
|
||||
onClick={() => setViewMode('config')}
|
||||
/>
|
||||
<QuickActionItem
|
||||
icon={Terminal}
|
||||
label="Logs"
|
||||
isActive={viewMode === 'logs'}
|
||||
onClick={() => setViewMode('logs')}
|
||||
/>
|
||||
</div>
|
||||
{/* Variants Section */}
|
||||
{variants.length > 0 && (
|
||||
<>
|
||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
|
||||
<GitBranch className="w-3 h-3" />
|
||||
Variants
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{variants.map((variant) => (
|
||||
<VariantSidebarItem
|
||||
key={variant.name}
|
||||
variant={variant}
|
||||
parentAuth={providers.find((p) => p.provider === variant.provider)}
|
||||
isSelected={selectedVariant === variant.name}
|
||||
onSelect={() => handleSelectVariant(variant.name)}
|
||||
onDelete={() => deleteMutation.mutate(variant.name)}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -547,23 +435,59 @@ export function CliproxyPage() {
|
||||
|
||||
{/* Right Panel */}
|
||||
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
||||
{viewMode === 'config' ? (
|
||||
<div className="flex-1 p-4">
|
||||
<ConfigSplitView />
|
||||
</div>
|
||||
) : viewMode === 'logs' ? (
|
||||
<div className="flex-1 p-4">
|
||||
<LogViewer />
|
||||
</div>
|
||||
{selectedVariantData && parentAuthForVariant ? (
|
||||
// Variant selected - show ProviderEditor with variant profile name
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
/>
|
||||
) : selectedStatus ? (
|
||||
<ProviderDetailPanel
|
||||
status={selectedStatus}
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
/>
|
||||
) : (
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
|
||||
Reference in New Issue
Block a user