mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +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 parent
f8648be6d9
commit
92b7065e10
26 files changed
+2712
-550
No files matched your search
@@ -1,8 +1,14 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify UI bundle size is under 1MB gzipped
|
* Verify UI bundle size stays reasonable
|
||||||
* React + shadcn/ui dashboard typically ranges 600-900KB
|
*
|
||||||
|
* 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');
|
const fs = require('fs');
|
||||||
@@ -10,7 +16,7 @@ const path = require('path');
|
|||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
|
|
||||||
const UI_DIR = path.join(__dirname, '../dist/ui');
|
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) {
|
function getGzipSize(filePath) {
|
||||||
const content = fs.readFileSync(filePath);
|
const content = fs.readFileSync(filePath);
|
||||||
@@ -40,9 +46,10 @@ if (!fs.existsSync(UI_DIR)) {
|
|||||||
|
|
||||||
const totalSize = walkDir(UI_DIR);
|
const totalSize = walkDir(UI_DIR);
|
||||||
const sizeKB = (totalSize / 1024).toFixed(1);
|
const sizeKB = (totalSize / 1024).toFixed(1);
|
||||||
|
const maxKB = (MAX_SIZE / 1024).toFixed(0);
|
||||||
|
|
||||||
if (totalSize > MAX_SIZE) {
|
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);
|
process.exit(1);
|
||||||
} else {
|
} else {
|
||||||
console.log(`[OK] Bundle size: ${sizeKB}KB gzipped`);
|
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 */
|
/** Internal API key for CCS-managed requests */
|
||||||
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
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
|
* Config version - bump when config format changes to trigger regeneration
|
||||||
* v1: Initial config (port, auth-dir, api-keys only)
|
* 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;
|
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}
|
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
|
||||||
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage)
|
||||||
# Generated: ${new Date().toISOString()}
|
# 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}
|
port: ${port}
|
||||||
debug: false
|
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
|
usage-statistics-enabled: true
|
||||||
|
|
||||||
# Management API for CCS dashboard (stats, control panel)
|
# Remote management API for CCS dashboard integration
|
||||||
remote-management:
|
remote-management:
|
||||||
allow-remote: false
|
allow-remote: true
|
||||||
secret-key: "${CCS_INTERNAL_API_KEY}"
|
secret-key: "${CCS_CONTROL_PANEL_SECRET}"
|
||||||
disable-control-panel: true
|
disable-control-panel: false
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Reliability & Quota Management
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
# Auto-retry on transient errors (403, 408, 500, 502, 503, 504)
|
||||||
request-retry: 3
|
request-retry: 0
|
||||||
max-retry-interval: 30
|
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:
|
quota-exceeded:
|
||||||
switch-project: true
|
switch-project: true
|
||||||
switch-preview-model: true
|
switch-preview-model: true
|
||||||
|
|
||||||
# CCS internal authentication
|
# =============================================================================
|
||||||
|
# Authentication
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
# API keys for CCS internal requests
|
||||||
api-keys:
|
api-keys:
|
||||||
- "${CCS_INTERNAL_API_KEY}"
|
- "${CCS_INTERNAL_API_KEY}"
|
||||||
|
|
||||||
# OAuth tokens stored in auth/ directory
|
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
||||||
auth-dir: "${authDir.replace(/\\/g, '/')}"
|
auth-dir: "${authDir.replace(/\\\\/g, '/')}"
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return config;
|
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
|
* @returns true if config should be regenerated
|
||||||
*/
|
*/
|
||||||
export function configNeedsRegeneration(): boolean {
|
export function configNeedsRegeneration(): boolean {
|
||||||
@@ -238,20 +269,7 @@ export function configNeedsRegeneration(): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const configVersion = parseInt(versionMatch[1], 10);
|
const configVersion = parseInt(versionMatch[1], 10);
|
||||||
if (configVersion < CLIPROXY_CONFIG_VERSION) {
|
return 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;
|
|
||||||
} catch {
|
} catch {
|
||||||
return true; // Error reading = regenerate
|
return true; // Error reading = regenerate
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,3 +117,7 @@ export {
|
|||||||
OPENROUTER_TEMPLATE,
|
OPENROUTER_TEMPLATE,
|
||||||
TOGETHER_TEMPLATE,
|
TOGETHER_TEMPLATE,
|
||||||
} from './openai-compat-manager';
|
} 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.
|
* 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 */
|
/** Usage statistics from CLIProxyAPI */
|
||||||
export interface CliproxyStats {
|
export interface CliproxyStats {
|
||||||
@@ -70,7 +70,7 @@ export async function fetchCliproxyStats(
|
|||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
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
|
* Check if CLIProxyAPI is running and responsive
|
||||||
* @param port CLIProxyAPI port (default: 8317)
|
* @param port CLIProxyAPI port (default: 8317)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
* Config Command Handler
|
* Config Command Handler
|
||||||
*
|
*
|
||||||
* Launches web-based configuration dashboard.
|
* Launches web-based configuration dashboard.
|
||||||
|
* Ensures CLIProxy service is running for dashboard features.
|
||||||
* Usage: ccs config [--port PORT] [--dev]
|
* Usage: ccs config [--port PORT] [--dev]
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -9,7 +10,9 @@ import getPort from 'get-port';
|
|||||||
import open from 'open';
|
import open from 'open';
|
||||||
import { startServer } from '../web-server';
|
import { startServer } from '../web-server';
|
||||||
import { setupGracefulShutdown } from '../web-server/shutdown';
|
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 {
|
interface ConfigOptions {
|
||||||
port?: number;
|
port?: number;
|
||||||
@@ -72,10 +75,31 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
|
|||||||
await initUI();
|
await initUI();
|
||||||
|
|
||||||
const options = parseArgs(args);
|
const options = parseArgs(args);
|
||||||
|
const verbose = options.dev || false;
|
||||||
|
|
||||||
console.log(header('CCS Config Dashboard'));
|
console.log(header('CCS Config Dashboard'));
|
||||||
console.log('');
|
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
|
// Find available port
|
||||||
const port =
|
const port =
|
||||||
|
|||||||
@@ -52,12 +52,26 @@ export interface Config {
|
|||||||
export type EnvValue = string;
|
export type EnvValue = string;
|
||||||
export type EnvVars = Record<string, EnvValue>;
|
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
|
* Claude CLI settings.json structure
|
||||||
* Located at: ~/.claude/settings.json or profile-specific
|
* Located at: ~/.claude/settings.json or profile-specific
|
||||||
*/
|
*/
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
env?: EnvVars;
|
env?: EnvVars;
|
||||||
|
/** Saved model presets for this provider */
|
||||||
|
presets?: ModelPreset[];
|
||||||
[key: string]: unknown; // Allow other settings
|
[key: string]: unknown; // Allow other settings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+124
-1
@@ -12,7 +12,11 @@ import { Config, Settings } from '../types/config';
|
|||||||
import { expandPath } from '../utils/helpers';
|
import { expandPath } from '../utils/helpers';
|
||||||
import { runHealthChecks, fixHealthIssue } from './health-service';
|
import { runHealthChecks, fixHealthIssue } from './health-service';
|
||||||
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
import { getAllAuthStatus, getOAuthConfig, initializeAccounts } from '../cliproxy/auth-handler';
|
||||||
import { fetchCliproxyStats, isCliproxyRunning } from '../cliproxy/stats-fetcher';
|
import {
|
||||||
|
fetchCliproxyStats,
|
||||||
|
fetchCliproxyModels,
|
||||||
|
isCliproxyRunning,
|
||||||
|
} from '../cliproxy/stats-fetcher';
|
||||||
import {
|
import {
|
||||||
listOpenAICompatProviders,
|
listOpenAICompatProviders,
|
||||||
getOpenAICompatProvider,
|
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 ====================
|
// ==================== 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
|
// 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(() =>
|
const CliproxyPage = lazy(() =>
|
||||||
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage }))
|
||||||
);
|
);
|
||||||
|
const CliproxyControlPanelPage = lazy(() =>
|
||||||
|
import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage }))
|
||||||
|
);
|
||||||
const AccountsPage = lazy(() =>
|
const AccountsPage = lazy(() =>
|
||||||
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
import('@/pages/accounts').then((m) => ({ default: m.AccountsPage }))
|
||||||
);
|
);
|
||||||
@@ -71,6 +74,7 @@ export default function App() {
|
|||||||
<Route path="/analytics" element={<AnalyticsPage />} />
|
<Route path="/analytics" element={<AnalyticsPage />} />
|
||||||
<Route path="/api" element={<ApiPage />} />
|
<Route path="/api" element={<ApiPage />} />
|
||||||
<Route path="/cliproxy" element={<CliproxyPage />} />
|
<Route path="/cliproxy" element={<CliproxyPage />} />
|
||||||
|
<Route path="/cliproxy/control-panel" element={<CliproxyControlPanelPage />} />
|
||||||
<Route path="/accounts" element={<AccountsPage />} />
|
<Route path="/accounts" element={<AccountsPage />} />
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
<Route path="/health" element={<HealthPage />} />
|
<Route path="/health" element={<HealthPage />} />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
Gauge,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Sidebar,
|
Sidebar,
|
||||||
@@ -43,7 +44,16 @@ const navGroups = [
|
|||||||
title: 'Identity & Access',
|
title: 'Identity & Access',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/api', icon: Key, label: 'API Profiles' },
|
{ 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',
|
path: '/accounts',
|
||||||
icon: Users,
|
icon: Users,
|
||||||
@@ -69,12 +79,15 @@ export function AppSidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { state } = useSidebar();
|
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;
|
const isRouteActive = (path: string) => location.pathname === path;
|
||||||
|
|
||||||
// Helper to check if a group/parent should be open based on active child
|
// 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 }[]) => {
|
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 (
|
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 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 { useMemo } from 'react';
|
||||||
import {
|
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||||
Select,
|
import { Badge } from '@/components/ui/badge';
|
||||||
SelectContent,
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
SelectItem,
|
import { Cpu, AlertCircle } from 'lucide-react';
|
||||||
SelectTrigger,
|
import { useCliproxyModels } from '@/hooks/use-cliproxy';
|
||||||
SelectValue,
|
import { cn } from '@/lib/utils';
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { useCliproxyModels, useUpdateModel } from '@/hooks/use-cliproxy';
|
|
||||||
|
|
||||||
interface ProviderModelSelectProps {
|
/** Category display configuration */
|
||||||
provider: string;
|
const CATEGORY_CONFIG: Record<string, { name: string; color: string; bgColor: string }> = {
|
||||||
displayName: string;
|
google: { name: 'Google (Gemini)', color: 'text-blue-600', bgColor: 'bg-blue-50' },
|
||||||
currentModel: string;
|
openai: { name: 'OpenAI (GPT)', color: 'text-green-600', bgColor: 'bg-green-50' },
|
||||||
availableModels: string[];
|
anthropic: { name: 'Anthropic (Claude)', color: 'text-orange-600', bgColor: 'bg-orange-50' },
|
||||||
onModelChange: (model: string) => void;
|
antigravity: { name: 'Antigravity', color: 'text-purple-600', bgColor: 'bg-purple-50' },
|
||||||
isUpdating: boolean;
|
other: { name: 'Other', color: 'text-gray-600', bgColor: 'bg-gray-50' },
|
||||||
}
|
};
|
||||||
|
|
||||||
function ProviderIcon({ provider }: { provider: string }) {
|
function getCategoryDisplay(category: 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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between p-3 border rounded-lg">
|
CATEGORY_CONFIG[category.toLowerCase()] || {
|
||||||
<div className="flex items-center gap-2">
|
name: category.charAt(0).toUpperCase() + category.slice(1),
|
||||||
<ProviderIcon provider={provider} />
|
color: 'text-gray-600',
|
||||||
<span className="font-medium">{displayName}</span>
|
bgColor: 'bg-gray-50',
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,12 +34,12 @@ function ModelPreferencesSkeleton() {
|
|||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Model Preferences</CardTitle>
|
<CardTitle className="text-base">Available Models</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{[1, 2, 3, 4].map((i) => (
|
{[1, 2, 3].map((i) => (
|
||||||
<div key={i} className="h-14 bg-muted animate-pulse rounded-lg" />
|
<Skeleton key={i} className="h-32 rounded-lg" />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@@ -89,50 +47,93 @@ function ModelPreferencesSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ModelPreferencesGrid() {
|
function EmptyModelsState() {
|
||||||
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 ?? [],
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Model Preferences</CardTitle>
|
<CardTitle className="text-base">Available Models</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||||
{providers.map((p) => {
|
<div className="w-12 h-12 rounded-full bg-muted flex items-center justify-center mb-3">
|
||||||
const models = getProviderModels(p.id);
|
<AlertCircle className="w-6 h-6 text-muted-foreground" />
|
||||||
return (
|
</div>
|
||||||
<ProviderModelSelect
|
<p className="text-sm text-muted-foreground mb-1">No models available</p>
|
||||||
key={p.id}
|
<p className="text-xs text-muted-foreground">
|
||||||
provider={p.id}
|
Start a CLIProxy session to fetch available models
|
||||||
displayName={p.displayName}
|
</p>
|
||||||
currentModel={models.current}
|
</div>
|
||||||
availableModels={models.available}
|
</CardContent>
|
||||||
onModelChange={(model) => updateModel.mutate({ provider: p.id, model })}
|
</Card>
|
||||||
isUpdating={updateModel.isPending && updateModel.variables?.provider === p.id}
|
);
|
||||||
/>
|
}
|
||||||
);
|
|
||||||
})}
|
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>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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
|
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 { 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';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
export function useCliproxy() {
|
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;
|
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 */
|
/** Provider accounts summary */
|
||||||
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
|
||||||
|
|
||||||
@@ -122,6 +137,23 @@ export interface SecretsExists {
|
|||||||
keys: string[];
|
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
|
// API
|
||||||
export const api = {
|
export const api = {
|
||||||
profiles: {
|
profiles: {
|
||||||
@@ -155,10 +187,7 @@ export const api = {
|
|||||||
|
|
||||||
// Stats and models for Overview tab
|
// Stats and models for Overview tab
|
||||||
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
stats: () => request<{ usage: Record<string, unknown> }>('/cliproxy/usage'),
|
||||||
models: () =>
|
models: () => request<CliproxyModelsResponse>('/cliproxy/models'),
|
||||||
request<{
|
|
||||||
providers: Record<string, { currentModel: string; availableModels: string[] }>;
|
|
||||||
}>('/cliproxy/models'),
|
|
||||||
updateModel: (provider: string, model: string) =>
|
updateModel: (provider: string, model: string) =>
|
||||||
request(`/cliproxy/models/${provider}`, {
|
request(`/cliproxy/models/${provider}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
@@ -240,4 +269,17 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
exists: (profile: string) => request<SecretsExists>(`/secrets/${profile}/exists`),
|
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
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 { DateRangeFilter } from '@/components/analytics/date-range-filter';
|
||||||
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
import { UsageSummaryCards } from '@/components/analytics/usage-summary-cards';
|
||||||
import { UsageTrendChart } from '@/components/analytics/usage-trend-chart';
|
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 { ModelDetailsContent } from '@/components/analytics/model-details-content';
|
||||||
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
import { SessionStatsCard } from '@/components/analytics/session-stats-card';
|
||||||
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
import { CliproxyStatsCard } from '@/components/analytics/cliproxy-stats-card';
|
||||||
import { UsageInsightsCard } from '@/components/analytics/usage-insights-card';
|
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight } from 'lucide-react';
|
||||||
import { TrendingUp, PieChart, RefreshCw, DollarSign, ChevronRight, Lightbulb } from 'lucide-react';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import {
|
import {
|
||||||
useUsageSummary,
|
useUsageSummary,
|
||||||
useUsageTrends,
|
useUsageTrends,
|
||||||
useModelUsage,
|
useModelUsage,
|
||||||
useRefreshUsage,
|
useRefreshUsage,
|
||||||
useUsageStatus,
|
useUsageStatus,
|
||||||
useUsageInsights,
|
|
||||||
useSessions,
|
useSessions,
|
||||||
type ModelUsage,
|
type ModelUsage,
|
||||||
} from '@/hooks/use-usage';
|
} from '@/hooks/use-usage';
|
||||||
@@ -75,7 +72,6 @@ export function AnalyticsPage() {
|
|||||||
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
const { data: summary, isLoading: isSummaryLoading } = useUsageSummary(apiOptions);
|
||||||
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
const { data: trends, isLoading: isTrendsLoading } = useUsageTrends(apiOptions);
|
||||||
const { data: models, isLoading: isModelsLoading } = useModelUsage(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: sessions, isLoading: isSessionsLoading } = useSessions({ ...apiOptions, limit: 3 });
|
||||||
const { data: status } = useUsageStatus();
|
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 && (
|
{lastUpdatedText && (
|
||||||
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
Updated {lastUpdatedText}
|
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
|
* CLIProxy Page - Master-Detail Layout
|
||||||
* Left sidebar: Provider navigation + Quick actions
|
* 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 { useState, useMemo } from 'react';
|
||||||
import { useQueryClient } from '@tanstack/react-query';
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Separator } from '@/components/ui/separator';
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import {
|
import { Check, X, RefreshCw, Sparkles, Zap, GitBranch, Trash2 } from 'lucide-react';
|
||||||
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 { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||||
import { AddAccountDialog } from '@/components/add-account-dialog';
|
import { AddAccountDialog } from '@/components/add-account-dialog';
|
||||||
import { ConfigSplitView } from '@/components/cliproxy/config/config-split-view';
|
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||||
import { LogViewer } from '@/components/cliproxy/logs/log-viewer';
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
|
import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector';
|
||||||
import {
|
import {
|
||||||
useCliproxy,
|
useCliproxy,
|
||||||
useCliproxyAuth,
|
useCliproxyAuth,
|
||||||
useSetDefaultAccount,
|
useSetDefaultAccount,
|
||||||
useRemoveAccount,
|
useRemoveAccount,
|
||||||
useCliproxyModels,
|
useDeleteVariant,
|
||||||
useUpdateModel,
|
|
||||||
} from '@/hooks/use-cliproxy';
|
} from '@/hooks/use-cliproxy';
|
||||||
import { useCliproxyStats } from '@/hooks/use-cliproxy-stats';
|
import type { AuthStatus, Variant } from '@/lib/api-client';
|
||||||
import type { OAuthAccount, AuthStatus } from '@/lib/api-client';
|
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
type ViewMode = 'overview' | 'config' | 'logs';
|
// Model catalog data - mirrors src/cliproxy/model-catalog.ts
|
||||||
|
const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||||
// Provider icon component
|
agy: {
|
||||||
function ProviderIcon({ provider, className }: { provider: string; className?: string }) {
|
provider: 'agy',
|
||||||
const iconMap: Record<string, { bg: string; text: string; letter: string }> = {
|
displayName: 'Antigravity',
|
||||||
gemini: { bg: 'bg-blue-500/10', text: 'text-blue-600', letter: 'G' },
|
defaultModel: 'gemini-claude-opus-4-5-thinking',
|
||||||
claude: { bg: 'bg-orange-500/10', text: 'text-orange-600', letter: 'C' },
|
models: [
|
||||||
codex: { bg: 'bg-green-500/10', text: 'text-green-600', letter: 'X' },
|
{
|
||||||
agy: { bg: 'bg-purple-500/10', text: 'text-purple-600', letter: 'A' },
|
id: 'gemini-claude-opus-4-5-thinking',
|
||||||
};
|
name: 'Claude Opus 4.5 Thinking',
|
||||||
const config = iconMap[provider.toLowerCase()] || {
|
description: 'Most capable, extended thinking',
|
||||||
bg: 'bg-gray-500/10',
|
},
|
||||||
text: 'text-gray-600',
|
{
|
||||||
letter: provider[0]?.toUpperCase() || '?',
|
id: 'gemini-claude-sonnet-4-5-thinking',
|
||||||
};
|
name: 'Claude Sonnet 4.5 Thinking',
|
||||||
|
description: 'Balanced with extended thinking',
|
||||||
return (
|
},
|
||||||
<div
|
{
|
||||||
className={cn(
|
id: 'gemini-claude-sonnet-4-5',
|
||||||
'flex items-center justify-center w-8 h-8 rounded-lg font-semibold text-sm',
|
name: 'Claude Sonnet 4.5',
|
||||||
config.bg,
|
description: 'Fast and capable',
|
||||||
config.text,
|
},
|
||||||
className
|
{
|
||||||
)}
|
id: 'gemini-3-pro-preview',
|
||||||
>
|
name: 'Gemini 3 Pro',
|
||||||
{config.letter}
|
description: 'Google latest model via Antigravity',
|
||||||
</div>
|
},
|
||||||
);
|
],
|
||||||
}
|
},
|
||||||
|
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
|
// Sidebar provider item
|
||||||
function ProviderSidebarItem({
|
function ProviderSidebarItem({
|
||||||
@@ -110,7 +160,7 @@ function ProviderSidebarItem({
|
|||||||
)}
|
)}
|
||||||
onClick={onSelect}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<ProviderIcon provider={status.provider} />
|
<ProviderLogo provider={status.provider} size="md" />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-medium text-sm truncate">{status.displayName}</span>
|
<span className="font-medium text-sm truncate">{status.displayName}</span>
|
||||||
@@ -138,259 +188,70 @@ function ProviderSidebarItem({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Quick action item in sidebar
|
// Sidebar variant item (user-created provider variants)
|
||||||
function QuickActionItem({
|
function VariantSidebarItem({
|
||||||
icon: Icon,
|
variant,
|
||||||
label,
|
parentAuth,
|
||||||
isActive,
|
isSelected,
|
||||||
onClick,
|
onSelect,
|
||||||
|
onDelete,
|
||||||
|
isDeleting,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ElementType;
|
variant: Variant;
|
||||||
label: string;
|
parentAuth?: AuthStatus;
|
||||||
isActive: boolean;
|
isSelected: boolean;
|
||||||
onClick: () => void;
|
onSelect: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
isDeleting?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left text-sm',
|
'w-full flex items-center gap-3 px-3 py-2 rounded-lg transition-colors cursor-pointer text-left pl-6',
|
||||||
isActive ? 'bg-muted font-medium' : 'hover:bg-muted/50 text-muted-foreground'
|
isSelected
|
||||||
|
? 'bg-primary/10 border border-primary/20'
|
||||||
|
: 'hover:bg-muted border border-transparent'
|
||||||
)}
|
)}
|
||||||
onClick={onClick}
|
onClick={onSelect}
|
||||||
>
|
>
|
||||||
<Icon className="w-4 h-4" />
|
<div className="relative">
|
||||||
<span>{label}</span>
|
<ProviderLogo provider={variant.provider} size="sm" />
|
||||||
</button>
|
<GitBranch className="w-2.5 h-2.5 absolute -bottom-0.5 -right-0.5 text-muted-foreground" />
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
<DropdownMenu>
|
<div className="flex items-center gap-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<span className="font-medium text-sm truncate">{variant.name}</span>
|
||||||
<Button variant="ghost" size="icon" className="h-7 w-7">
|
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||||
<MoreHorizontal className="w-4 h-4" />
|
variant
|
||||||
</Button>
|
</Badge>
|
||||||
</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>
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
<Button onClick={onAddAccount} className="gap-2">
|
{parentAuth?.authenticated ? (
|
||||||
<Plus className="w-4 h-4" />
|
<>
|
||||||
Add Account
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
</Button>
|
<span className="text-xs text-muted-foreground truncate">via {variant.provider}</span>
|
||||||
</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 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">
|
<X className="w-3 h-3 text-muted-foreground" />
|
||||||
<User className="w-6 h-6 text-muted-foreground" />
|
<span className="text-xs text-muted-foreground">Parent not connected</span>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</div>
|
||||||
</Card>
|
</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">
|
<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" />
|
<Zap className="w-8 h-8 text-muted-foreground" />
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-xl font-semibold mb-2">CLIProxy Manager</h2>
|
<h2 className="text-xl font-semibold mb-2">CCS Profile Manager</h2>
|
||||||
<p className="text-muted-foreground mb-6">
|
<p className="text-muted-foreground mb-4">
|
||||||
Manage OAuth authentication for Claude CLI proxy providers. Select a provider from the
|
Manage OAuth authentication, account preferences, and model selection for CLIProxy
|
||||||
sidebar or run the quick setup wizard.
|
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>
|
</p>
|
||||||
<Button onClick={onSetup} className="gap-2">
|
<Button onClick={onSetup} className="gap-2">
|
||||||
<Sparkles className="w-4 h-4" />
|
<Sparkles className="w-4 h-4" />
|
||||||
@@ -419,39 +287,61 @@ function EmptyProviderState({ onSetup }: { onSetup: () => void }) {
|
|||||||
export function CliproxyPage() {
|
export function CliproxyPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { data: authData, isLoading: authLoading } = useCliproxyAuth();
|
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 [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 [wizardOpen, setWizardOpen] = useState(false);
|
||||||
const [addAccountProvider, setAddAccountProvider] = useState<{
|
const [addAccountProvider, setAddAccountProvider] = useState<{
|
||||||
provider: string;
|
provider: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
// Auto-select first provider if none selected
|
|
||||||
const providers = authData?.authStatus || [];
|
const providers = authData?.authStatus || [];
|
||||||
|
const variants = variantsData?.variants || [];
|
||||||
|
|
||||||
|
// Auto-select first provider if nothing selected
|
||||||
const effectiveProvider = useMemo(() => {
|
const effectiveProvider = useMemo(() => {
|
||||||
|
// If a variant is selected, no provider is effective
|
||||||
|
if (selectedVariant) return null;
|
||||||
if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) {
|
if (selectedProvider && providers.some((p) => p.provider === selectedProvider)) {
|
||||||
return selectedProvider;
|
return selectedProvider;
|
||||||
}
|
}
|
||||||
return providers.length > 0 ? providers[0].provider : null;
|
return providers.length > 0 ? providers[0].provider : null;
|
||||||
}, [selectedProvider, providers]);
|
}, [selectedProvider, selectedVariant, providers]);
|
||||||
|
|
||||||
const selectedStatus = providers.find((p) => p.provider === effectiveProvider);
|
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 = () => {
|
const handleRefresh = () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy'] });
|
||||||
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
queryClient.invalidateQueries({ queryKey: ['cliproxy-auth'] });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSelectProvider = (provider: string) => {
|
||||||
|
setSelectedProvider(provider);
|
||||||
|
setSelectedVariant(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelectVariant = (variantName: string) => {
|
||||||
|
setSelectedVariant(variantName);
|
||||||
|
setSelectedProvider(null);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-[calc(100vh-100px)] flex">
|
<div className="h-[calc(100vh-100px)] flex">
|
||||||
{/* Left Sidebar */}
|
{/* Left Sidebar */}
|
||||||
<div className="w-64 border-r flex flex-col bg-muted/30">
|
<div className="w-64 border-r flex flex-col bg-muted/30">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-4 border-b bg-background">
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<Zap className="w-5 h-5 text-primary" />
|
<Zap className="w-5 h-5 text-primary" />
|
||||||
<h1 className="font-semibold">CLIProxy</h1>
|
<h1 className="font-semibold">CLIProxy</h1>
|
||||||
@@ -466,6 +356,7 @@ export function CliproxyPage() {
|
|||||||
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
<RefreshCw className={cn('w-4 h-4', isFetching && 'animate-spin')} />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-3">CCS-level account management</p>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -496,38 +387,35 @@ export function CliproxyPage() {
|
|||||||
<ProviderSidebarItem
|
<ProviderSidebarItem
|
||||||
key={status.provider}
|
key={status.provider}
|
||||||
status={status}
|
status={status}
|
||||||
isSelected={effectiveProvider === status.provider && viewMode === 'overview'}
|
isSelected={effectiveProvider === status.provider}
|
||||||
onSelect={() => {
|
onSelect={() => handleSelectProvider(status.provider)}
|
||||||
setSelectedProvider(status.provider);
|
|
||||||
setViewMode('overview');
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Separator className="my-2" />
|
{/* Variants Section */}
|
||||||
|
{variants.length > 0 && (
|
||||||
{/* Quick Actions */}
|
<>
|
||||||
<div className="p-2">
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2 mt-4 flex items-center gap-1.5">
|
||||||
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide px-3 py-2">
|
<GitBranch className="w-3 h-3" />
|
||||||
Tools
|
Variants
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<QuickActionItem
|
{variants.map((variant) => (
|
||||||
icon={FileText}
|
<VariantSidebarItem
|
||||||
label="Config Editor"
|
key={variant.name}
|
||||||
isActive={viewMode === 'config'}
|
variant={variant}
|
||||||
onClick={() => setViewMode('config')}
|
parentAuth={providers.find((p) => p.provider === variant.provider)}
|
||||||
/>
|
isSelected={selectedVariant === variant.name}
|
||||||
<QuickActionItem
|
onSelect={() => handleSelectVariant(variant.name)}
|
||||||
icon={Terminal}
|
onDelete={() => deleteMutation.mutate(variant.name)}
|
||||||
label="Logs"
|
isDeleting={deleteMutation.isPending}
|
||||||
isActive={viewMode === 'logs'}
|
/>
|
||||||
onClick={() => setViewMode('logs')}
|
))}
|
||||||
/>
|
</div>
|
||||||
</div>
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
@@ -547,23 +435,59 @@ export function CliproxyPage() {
|
|||||||
|
|
||||||
{/* Right Panel */}
|
{/* Right Panel */}
|
||||||
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
<div className="flex-1 flex flex-col min-w-0 bg-background">
|
||||||
{viewMode === 'config' ? (
|
{selectedVariantData && parentAuthForVariant ? (
|
||||||
<div className="flex-1 p-4">
|
// Variant selected - show ProviderEditor with variant profile name
|
||||||
<ConfigSplitView />
|
<ProviderEditor
|
||||||
</div>
|
provider={selectedVariantData.name}
|
||||||
) : viewMode === 'logs' ? (
|
displayName={`${selectedVariantData.name} (${selectedVariantData.provider} variant)`}
|
||||||
<div className="flex-1 p-4">
|
authStatus={parentAuthForVariant}
|
||||||
<LogViewer />
|
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||||
</div>
|
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 ? (
|
) : selectedStatus ? (
|
||||||
<ProviderDetailPanel
|
<ProviderEditor
|
||||||
status={selectedStatus}
|
provider={selectedStatus.provider}
|
||||||
|
displayName={selectedStatus.displayName}
|
||||||
|
authStatus={selectedStatus}
|
||||||
|
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||||
onAddAccount={() =>
|
onAddAccount={() =>
|
||||||
setAddAccountProvider({
|
setAddAccountProvider({
|
||||||
provider: selectedStatus.provider,
|
provider: selectedStatus.provider,
|
||||||
displayName: selectedStatus.displayName,
|
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)} />
|
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||||
|
|||||||
Reference in new issue
Block a user