mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 12:19:35 +00:00
refactor(ui): split provider-editor into cliproxy/provider-editor/ directory
- extract 921-line component into 13 focused modules - move cliproxy-dialog, cliproxy-table, cliproxy-stats-overview - add barrel export in cliproxy/index.ts
This commit is contained in:
@@ -0,0 +1,157 @@
|
|||||||
|
/**
|
||||||
|
* CLIProxy Variant Dialog Component
|
||||||
|
* Phase 03: REST API Routes & CRUD
|
||||||
|
* Phase 06: Multi-Account Support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useForm, useWatch } from 'react-hook-form';
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import * as z from 'zod';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||||
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
|
|
||||||
|
const providers = ['gemini', 'codex', 'agy', 'qwen', 'iflow'] as const;
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z
|
||||||
|
.string()
|
||||||
|
.min(1, 'Name is required')
|
||||||
|
.regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'),
|
||||||
|
provider: z.enum(providers, { message: 'Provider is required' }),
|
||||||
|
model: z.string().optional(),
|
||||||
|
account: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
type FormData = z.infer<typeof schema>;
|
||||||
|
|
||||||
|
interface CliproxyDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerOptions = [
|
||||||
|
{ value: 'gemini', label: 'Google Gemini' },
|
||||||
|
{ value: 'codex', label: 'OpenAI Codex' },
|
||||||
|
{ value: 'agy', label: 'Antigravity' },
|
||||||
|
{ value: 'qwen', label: 'Alibaba Qwen' },
|
||||||
|
{ value: 'iflow', label: 'iFlow' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) {
|
||||||
|
const createMutation = useCreateVariant();
|
||||||
|
const { data: authData } = useCliproxyAuth();
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
control,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
} = useForm<FormData>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Watch provider to show relevant accounts
|
||||||
|
const selectedProvider = useWatch({ control, name: 'provider' });
|
||||||
|
|
||||||
|
// Get accounts for selected provider
|
||||||
|
const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider);
|
||||||
|
const providerAccounts = providerAuth?.accounts || [];
|
||||||
|
|
||||||
|
const onSubmit = async (data: FormData) => {
|
||||||
|
try {
|
||||||
|
await createMutation.mutateAsync(data);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to create variant:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Create CLIProxy Variant</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="name">Name</Label>
|
||||||
|
<Input id="name" {...register('name')} placeholder="my-gemini" />
|
||||||
|
{errors.name && <span className="text-xs text-red-500">{errors.name.message}</span>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="provider">Provider</Label>
|
||||||
|
<select
|
||||||
|
id="provider"
|
||||||
|
{...register('provider')}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Select provider...</option>
|
||||||
|
{providerOptions.map((opt) => (
|
||||||
|
<option key={opt.value} value={opt.value}>
|
||||||
|
{opt.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{errors.provider && (
|
||||||
|
<span className="text-xs text-red-500">{errors.provider.message}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Account selector - only show if provider has accounts */}
|
||||||
|
{selectedProvider && providerAccounts.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="account">Account</Label>
|
||||||
|
<select
|
||||||
|
id="account"
|
||||||
|
{...register('account')}
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="">Use default account</option>
|
||||||
|
{providerAccounts.map((acc) => (
|
||||||
|
<option key={acc.id} value={acc.id}>
|
||||||
|
{privacyMode ? '••••••' : acc.email || acc.id}
|
||||||
|
{acc.isDefault ? ' (default)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-xs text-muted-foreground mt-1 block">
|
||||||
|
Select which OAuth account this variant should use
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show message if provider selected but no accounts */}
|
||||||
|
{selectedProvider && providerAccounts.length === 0 && providerAuth && (
|
||||||
|
<div className="text-sm text-amber-600 bg-amber-50 dark:bg-amber-950/20 p-3 rounded-md">
|
||||||
|
No accounts authenticated for {providerAuth.displayName}.
|
||||||
|
<br />
|
||||||
|
<code className="text-xs bg-muted px-1 rounded">ccs {selectedProvider} --auth</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="model">Model (optional)</Label>
|
||||||
|
<Input id="model" {...register('model')} placeholder="gemini-2.5-pro" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button type="button" variant="outline" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,349 @@
|
|||||||
|
/**
|
||||||
|
* CLIProxy Stats Overview Component
|
||||||
|
*
|
||||||
|
* Full-width dashboard section showing comprehensive CLIProxyAPI statistics.
|
||||||
|
* Features:
|
||||||
|
* - Status indicator with uptime
|
||||||
|
* - Request metrics with success rate visualization
|
||||||
|
* - Token usage with cost estimation
|
||||||
|
* - Model breakdown with usage distribution
|
||||||
|
* - Session history
|
||||||
|
* Respects privacy mode to blur sensitive data.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
|
import {
|
||||||
|
Server,
|
||||||
|
Zap,
|
||||||
|
ZapOff,
|
||||||
|
Activity,
|
||||||
|
Coins,
|
||||||
|
Cpu,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
TrendingUp,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useCliproxyStats, useCliproxyStatus } from '@/hooks/use-cliproxy-stats';
|
||||||
|
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
|
|
||||||
|
interface CliproxyStatsOverviewProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CliproxyStatsOverview({ className }: CliproxyStatsOverviewProps) {
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
const { data: status, isLoading: statusLoading } = useCliproxyStatus();
|
||||||
|
const { data: stats, isLoading: statsLoading, error } = useCliproxyStats(status?.running);
|
||||||
|
|
||||||
|
const isLoading = statusLoading || (status?.running && statsLoading);
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-4 w-72" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-8 w-24" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
{[1, 2, 3, 4].map((i) => (
|
||||||
|
<Skeleton key={i} className="h-28" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Offline state
|
||||||
|
if (!status?.running) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
Session Statistics
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Real-time usage metrics from CLIProxyAPI
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="secondary" className="w-fit gap-1.5">
|
||||||
|
<ZapOff className="h-3.5 w-3.5" />
|
||||||
|
Offline
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
|
||||||
|
<div className="p-4 rounded-full bg-muted/50 mb-4">
|
||||||
|
<Server className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<h3 className="font-medium mb-1">No Active Session</h3>
|
||||||
|
<p className="text-sm text-muted-foreground max-w-md">
|
||||||
|
Start a CLIProxy session using{' '}
|
||||||
|
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs gemini</code>,{' '}
|
||||||
|
<code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs codex</code>,
|
||||||
|
or <code className="px-1.5 py-0.5 bg-muted rounded text-xs font-mono">ccs agy</code>{' '}
|
||||||
|
to view real-time statistics.
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
<Card className="border-destructive/50">
|
||||||
|
<CardContent className="flex items-center gap-4 py-6">
|
||||||
|
<XCircle className="h-8 w-8 text-destructive shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="font-medium">Failed to Load Statistics</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">{error.message}</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate derived stats
|
||||||
|
const totalRequests = stats?.totalRequests ?? 0;
|
||||||
|
const failedRequests = stats?.quotaExceededCount ?? 0;
|
||||||
|
const successRequests = totalRequests - failedRequests;
|
||||||
|
const successRate = totalRequests > 0 ? Math.round((successRequests / totalRequests) * 100) : 100;
|
||||||
|
const totalTokens = stats?.tokens?.total ?? 0;
|
||||||
|
|
||||||
|
// Get model breakdown sorted by usage
|
||||||
|
const models = Object.entries(stats?.requestsByModel ?? {}).sort((a, b) => b[1] - a[1]);
|
||||||
|
const maxModelRequests = models.length > 0 ? models[0][1] : 1;
|
||||||
|
|
||||||
|
// Define color palette for models
|
||||||
|
const modelColors = [
|
||||||
|
'bg-blue-500',
|
||||||
|
'bg-purple-500',
|
||||||
|
'bg-amber-500',
|
||||||
|
'bg-emerald-500',
|
||||||
|
'bg-rose-500',
|
||||||
|
'bg-cyan-500',
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-4', className)}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-semibold tracking-tight flex items-center gap-2">
|
||||||
|
<Activity className="h-5 w-5" />
|
||||||
|
Session Statistics
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">Real-time usage metrics from CLIProxyAPI</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="w-fit gap-1.5 text-green-600 border-green-200 bg-green-50 dark:bg-green-900/10 dark:border-green-800"
|
||||||
|
>
|
||||||
|
<Zap className="h-3.5 w-3.5" />
|
||||||
|
Running
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Grid */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{/* Requests Card */}
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Total Requests</p>
|
||||||
|
<p className="text-2xl font-bold">{formatNumber(totalRequests)}</p>
|
||||||
|
<div className="flex items-center gap-1.5 text-[10px]">
|
||||||
|
<CheckCircle2 className="h-3 w-3 text-green-500" />
|
||||||
|
<span className="text-muted-foreground">{successRequests} success</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/20">
|
||||||
|
<TrendingUp className="h-4 w-4 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Success Rate Card */}
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Success Rate</p>
|
||||||
|
<p className="text-2xl font-bold">{successRate}%</p>
|
||||||
|
<div className="h-1.5 mt-2 bg-muted/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all',
|
||||||
|
successRate >= 90 ? 'bg-green-500' : 'bg-amber-500'
|
||||||
|
)}
|
||||||
|
style={{ width: `${successRate}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'p-2 rounded-lg',
|
||||||
|
successRate >= 90
|
||||||
|
? 'bg-green-100 dark:bg-green-900/20'
|
||||||
|
: 'bg-amber-100 dark:bg-amber-900/20'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{successRate >= 90 ? (
|
||||||
|
<CheckCircle2 className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<XCircle className="h-4 w-4 text-amber-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Tokens Card */}
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Total Tokens</p>
|
||||||
|
<p className={cn('text-2xl font-bold', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
|
{formatNumber(totalTokens)}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={cn(
|
||||||
|
'text-[10px] text-muted-foreground',
|
||||||
|
privacyMode && PRIVACY_BLUR_CLASS
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
~${estimateCost(totalTokens).toFixed(2)} estimated
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900/20">
|
||||||
|
<Coins className="h-4 w-4 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Models Card */}
|
||||||
|
<Card className="hover:shadow-md transition-shadow">
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="text-xs font-medium text-muted-foreground">Models Used</p>
|
||||||
|
<p className="text-2xl font-bold">{models.length}</p>
|
||||||
|
<p className="text-[10px] text-muted-foreground">
|
||||||
|
{models.length > 0 ? formatModelName(models[0][0]) : 'None'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-2 rounded-lg bg-cyan-100 dark:bg-cyan-900/20">
|
||||||
|
<Cpu className="h-4 w-4 text-cyan-600" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Model Breakdown */}
|
||||||
|
{models.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Cpu className="h-4 w-4" />
|
||||||
|
Model Usage Distribution
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-0">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{models.map(([model, count], index) => {
|
||||||
|
const percentage = Math.round((count / totalRequests) * 100);
|
||||||
|
const barPercentage = Math.round((count / maxModelRequests) * 100);
|
||||||
|
const displayName = formatModelName(model);
|
||||||
|
const colorClass = modelColors[index % modelColors.length];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={model} className="group">
|
||||||
|
<div className="flex items-center justify-between text-sm mb-1.5">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className={cn('w-2 h-2 rounded-full shrink-0', colorClass)} />
|
||||||
|
<span className="font-medium truncate" title={model}>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-muted-foreground shrink-0">
|
||||||
|
<span>{count} requests</span>
|
||||||
|
<span className="text-xs font-medium w-10 text-right">{percentage}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 bg-muted/50 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full transition-all duration-500',
|
||||||
|
colorClass
|
||||||
|
)}
|
||||||
|
style={{ width: `${barPercentage}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format large numbers with K/M suffix */
|
||||||
|
function formatNumber(num: number): string {
|
||||||
|
if (num >= 1000000) {
|
||||||
|
return `${(num / 1000000).toFixed(1)}M`;
|
||||||
|
}
|
||||||
|
if (num >= 1000) {
|
||||||
|
return `${(num / 1000).toFixed(1)}K`;
|
||||||
|
}
|
||||||
|
return num.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format model names for display */
|
||||||
|
function formatModelName(model: string): string {
|
||||||
|
let name = model
|
||||||
|
.replace(/^gemini-claude-/, '')
|
||||||
|
.replace(/^gemini-/, '')
|
||||||
|
.replace(/^claude-/, '')
|
||||||
|
.replace(/^anthropic\./, '')
|
||||||
|
.replace(/-thinking$/, ' Thinking');
|
||||||
|
|
||||||
|
name = name
|
||||||
|
.split(/[-_]/)
|
||||||
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ');
|
||||||
|
|
||||||
|
if (name.length > 25) {
|
||||||
|
name = name.slice(0, 23) + '...';
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Estimate cost based on token count (rough average) */
|
||||||
|
function estimateCost(tokens: number): number {
|
||||||
|
// Average cost per 1M tokens across models (~$3 for input, ~$15 for output)
|
||||||
|
// Assuming 30% input, 70% output ratio
|
||||||
|
const avgCostPerMillion = 3 * 0.3 + 15 * 0.7;
|
||||||
|
return (tokens / 1000000) * avgCostPerMillion;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
/**
|
||||||
|
* CLIProxy Variants Table Component
|
||||||
|
* Phase 03: REST API Routes & CRUD
|
||||||
|
* Phase 06: Multi-Account Support
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from '@/components/ui/table';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { MoreHorizontal, Trash2, User } from 'lucide-react';
|
||||||
|
import { useDeleteVariant } from '@/hooks/use-cliproxy';
|
||||||
|
import type { Variant } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface CliproxyTableProps {
|
||||||
|
data: Variant[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const providerLabels: Record<string, string> = {
|
||||||
|
gemini: 'Google Gemini',
|
||||||
|
codex: 'OpenAI Codex',
|
||||||
|
agy: 'Antigravity',
|
||||||
|
qwen: 'Alibaba Qwen',
|
||||||
|
iflow: 'iFlow',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CliproxyTable({ data }: CliproxyTableProps) {
|
||||||
|
const deleteMutation = useDeleteVariant();
|
||||||
|
|
||||||
|
const columns: ColumnDef<Variant>[] = [
|
||||||
|
{
|
||||||
|
accessorKey: 'name',
|
||||||
|
header: 'Name',
|
||||||
|
cell: ({ row }) => <span className="font-medium">{row.original.name}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'provider',
|
||||||
|
header: 'Provider',
|
||||||
|
cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'account',
|
||||||
|
header: 'Account',
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const account = row.original.account;
|
||||||
|
if (!account) {
|
||||||
|
return <span className="text-muted-foreground text-xs italic">default</span>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="secondary" className="text-xs font-normal">
|
||||||
|
<User className="w-3 h-3 mr-1 opacity-70" />
|
||||||
|
{account}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'settings',
|
||||||
|
header: 'Settings Path',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<code className="text-[10px] bg-muted px-1.5 py-0.5 rounded text-muted-foreground">
|
||||||
|
{row.original.settings || `config.cliproxy.${row.original.name}`}
|
||||||
|
</code>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'actions',
|
||||||
|
header: 'Actions',
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||||
|
<span className="sr-only">Open menu</span>
|
||||||
|
<MoreHorizontal className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="bg-white dark:bg-zinc-950">
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-red-600 focus:text-red-600 focus:bg-red-50 dark:focus:bg-red-950/30"
|
||||||
|
onClick={() => deleteMutation.mutate(row.original.name)}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12 border rounded-lg bg-muted/5 border-dashed">
|
||||||
|
<div className="text-muted-foreground text-sm">No CLIProxy variants found.</div>
|
||||||
|
<div className="text-xs text-muted-foreground mt-1">
|
||||||
|
Create one to use OAuth-based providers with specific account configurations.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border rounded-md overflow-hidden bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/50">
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableHead key={header.id}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* CLIProxy Components Barrel Export
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Main cliproxy components
|
||||||
|
export { CategorizedModelSelector } from './categorized-model-selector';
|
||||||
|
export { CliproxyDialog } from './cliproxy-dialog';
|
||||||
|
export { CliproxyHeader } from './cliproxy-header';
|
||||||
|
export { CliproxyStatsOverview } from './cliproxy-stats-overview';
|
||||||
|
export { CliproxyTable } from './cliproxy-table';
|
||||||
|
export { CliproxyTabs } from './cliproxy-tabs';
|
||||||
|
export { ControlPanelEmbed } from './control-panel-embed';
|
||||||
|
export { ProviderLogo } from './provider-logo';
|
||||||
|
export { ProviderModelSelector } from './provider-model-selector';
|
||||||
|
|
||||||
|
// Provider editor (from subdirectory)
|
||||||
|
export { ProviderEditor } from './provider-editor';
|
||||||
|
export type { ProviderEditorProps, ModelMappingValues } from './provider-editor';
|
||||||
|
|
||||||
|
// Config components (from subdirectory)
|
||||||
|
export { ConfigSplitView } from './config/config-split-view';
|
||||||
|
export { DiffDialog } from './config/diff-dialog';
|
||||||
|
export { FileTree } from './config/file-tree';
|
||||||
|
export { YamlEditor } from './config/yaml-editor';
|
||||||
|
|
||||||
|
// Overview components (from subdirectory)
|
||||||
|
export { CredentialHealthList } from './overview/credential-health-list';
|
||||||
|
export { ModelPreferencesGrid } from './overview/model-preferences-grid';
|
||||||
|
export { QuickStatsRow } from './overview/quick-stats-row';
|
||||||
@@ -1,921 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Provider Editor Component
|
* Provider Editor Component
|
||||||
* Split-view editor for CLIProxy provider settings
|
* Re-exports from modular directory for backward compatibility
|
||||||
* Similar to ProfileEditor but tailored for provider configuration
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
export { ProviderEditor } from './provider-editor/index';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
export type { ProviderEditorProps, ModelMappingValues } from './provider-editor/types';
|
||||||
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';
|
|
||||||
import { CLIPROXY_PORT } from '@/lib/preset-utils';
|
|
||||||
import { GlobalEnvIndicator } from '@/components/global-env-indicator';
|
|
||||||
import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
|
||||||
|
|
||||||
// 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();
|
|
||||||
const { privacyMode } = usePrivacy();
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
};
|
|
||||||
// Always include BASE_URL and AUTH_TOKEN for CLIProxy providers
|
|
||||||
updateEnvValues({
|
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`,
|
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
|
||||||
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={() => {
|
|
||||||
// Always include BASE_URL and AUTH_TOKEN for CLIProxy providers
|
|
||||||
updateEnvValues({
|
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`,
|
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
|
||||||
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="outline"
|
|
||||||
size="sm"
|
|
||||||
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
|
||||||
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="default"
|
|
||||||
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}
|
|
||||||
privacyMode={privacyMode}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</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-4 pt-4">
|
|
||||||
<div className="h-full border rounded-md overflow-hidden bg-background">
|
|
||||||
<CodeEditor
|
|
||||||
value={rawJsonContent}
|
|
||||||
onChange={handleRawJsonChange}
|
|
||||||
language="json"
|
|
||||||
minHeight="100%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Global Env Indicator */}
|
|
||||||
<div className="mx-6 mb-4">
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<GlobalEnvIndicator profileEnv={settings?.env} />
|
|
||||||
</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?.path && (
|
|
||||||
<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) => {
|
|
||||||
// Always include BASE_URL and AUTH_TOKEN for CLIProxy providers
|
|
||||||
updateEnvValues({
|
|
||||||
ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`,
|
|
||||||
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
|
||||||
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,
|
|
||||||
privacyMode,
|
|
||||||
}: {
|
|
||||||
account: OAuthAccount;
|
|
||||||
onSetDefault: () => void;
|
|
||||||
onRemove: () => void;
|
|
||||||
isRemoving?: boolean;
|
|
||||||
privacyMode?: 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={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
|
||||||
{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,88 @@
|
|||||||
|
/**
|
||||||
|
* Account Item Component
|
||||||
|
* Displays a single OAuth account with actions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@/components/ui/dropdown-menu';
|
||||||
|
import { User, Star, MoreHorizontal, Clock, Trash2 } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
|
||||||
|
import type { AccountItemProps } from './types';
|
||||||
|
|
||||||
|
export function AccountItem({
|
||||||
|
account,
|
||||||
|
onSetDefault,
|
||||||
|
onRemove,
|
||||||
|
isRemoving,
|
||||||
|
privacyMode,
|
||||||
|
}: AccountItemProps) {
|
||||||
|
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={cn('font-medium text-sm', privacyMode && PRIVACY_BLUR_CLASS)}>
|
||||||
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
/**
|
||||||
|
* Accounts Section Component
|
||||||
|
* Manages connected OAuth accounts for a provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { User, Plus } from 'lucide-react';
|
||||||
|
import { AccountItem } from './account-item';
|
||||||
|
import type { OAuthAccount } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface AccountsSectionProps {
|
||||||
|
accounts: OAuthAccount[];
|
||||||
|
onAddAccount: () => void;
|
||||||
|
onSetDefault: (accountId: string) => void;
|
||||||
|
onRemoveAccount: (accountId: string) => void;
|
||||||
|
isRemovingAccount?: boolean;
|
||||||
|
privacyMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountsSection({
|
||||||
|
accounts,
|
||||||
|
onAddAccount,
|
||||||
|
onSetDefault,
|
||||||
|
onRemoveAccount,
|
||||||
|
isRemovingAccount,
|
||||||
|
privacyMode,
|
||||||
|
}: AccountsSectionProps) {
|
||||||
|
return (
|
||||||
|
<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="default" 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}
|
||||||
|
privacyMode={privacyMode}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* Custom Preset Dialog
|
||||||
|
* Configure all model mappings at once with save option
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Sparkles, Loader2, Star, Zap } from 'lucide-react';
|
||||||
|
import { FlexibleModelSelector } from '../provider-model-selector';
|
||||||
|
import type { CustomPresetDialogProps, ModelMappingValues } from './types';
|
||||||
|
|
||||||
|
export function CustomPresetDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
currentValues,
|
||||||
|
onApply,
|
||||||
|
onSave,
|
||||||
|
isSaving,
|
||||||
|
catalog,
|
||||||
|
allModels,
|
||||||
|
}: CustomPresetDialogProps) {
|
||||||
|
const [values, setValues] = useState<ModelMappingValues>(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,250 @@
|
|||||||
|
/**
|
||||||
|
* Provider Editor Component
|
||||||
|
* Split-view editor for CLIProxy provider settings
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { ConfirmDialog } from '@/components/shared/confirm-dialog';
|
||||||
|
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||||
|
import { Loader2, Code2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import {
|
||||||
|
useCliproxyModels,
|
||||||
|
usePresets,
|
||||||
|
useCreatePreset,
|
||||||
|
useDeletePreset,
|
||||||
|
} from '@/hooks/use-cliproxy';
|
||||||
|
import { CLIPROXY_PORT } from '@/lib/preset-utils';
|
||||||
|
import { usePrivacy } from '@/contexts/privacy-context';
|
||||||
|
import { useProviderEditor } from './use-provider-editor';
|
||||||
|
import { CustomPresetDialog } from './custom-preset-dialog';
|
||||||
|
import { RawEditorSection } from './raw-editor-section';
|
||||||
|
import { ProviderInfoTab } from './provider-info-tab';
|
||||||
|
import { ProviderEditorHeader } from './provider-editor-header';
|
||||||
|
import { ModelConfigTab } from './model-config-tab';
|
||||||
|
import type { ProviderEditorProps, ModelMappingValues } from './types';
|
||||||
|
|
||||||
|
export function ProviderEditor({
|
||||||
|
provider,
|
||||||
|
displayName,
|
||||||
|
authStatus,
|
||||||
|
catalog,
|
||||||
|
logoProvider,
|
||||||
|
onAddAccount,
|
||||||
|
onSetDefault,
|
||||||
|
onRemoveAccount,
|
||||||
|
isRemovingAccount,
|
||||||
|
}: ProviderEditorProps) {
|
||||||
|
const [customPresetOpen, setCustomPresetOpen] = useState(false);
|
||||||
|
const { privacyMode } = usePrivacy();
|
||||||
|
|
||||||
|
const { data: modelsData } = useCliproxyModels();
|
||||||
|
const { data: presetsData } = usePresets(provider);
|
||||||
|
const createPresetMutation = useCreatePreset();
|
||||||
|
const deletePresetMutation = useDeletePreset();
|
||||||
|
const savedPresets = presetsData?.presets || [];
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
rawJsonContent,
|
||||||
|
rawJsonEdits,
|
||||||
|
isRawJsonValid,
|
||||||
|
hasChanges,
|
||||||
|
currentModel,
|
||||||
|
opusModel,
|
||||||
|
sonnetModel,
|
||||||
|
haikuModel,
|
||||||
|
handleRawJsonChange,
|
||||||
|
updateEnvValue,
|
||||||
|
updateEnvValues,
|
||||||
|
saveMutation,
|
||||||
|
conflictDialog,
|
||||||
|
handleConflictResolve,
|
||||||
|
} = useProviderEditor(provider);
|
||||||
|
|
||||||
|
const accounts = authStatus.accounts || [];
|
||||||
|
|
||||||
|
const handleApplyPreset = (updates: Record<string, string>) => {
|
||||||
|
updateEnvValues({
|
||||||
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
...updates,
|
||||||
|
});
|
||||||
|
toast.success(`Applied "${updates.ANTHROPIC_MODEL?.split('/').pop() || 'preset'}" preset`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
|
||||||
|
updateEnvValues({
|
||||||
|
ANTHROPIC_BASE_URL: `http://127.0.0.1:${CLIPROXY_PORT}/api/provider/${provider}`,
|
||||||
|
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCustomPresetSave = (values: ModelMappingValues, presetName?: string) => {
|
||||||
|
if (!presetName) {
|
||||||
|
toast.error('Please enter a preset name to save');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createPresetMutation.mutate({ profile: provider, data: { name: presetName, ...values } });
|
||||||
|
setCustomPresetOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col overflow-hidden">
|
||||||
|
<ProviderEditorHeader
|
||||||
|
provider={provider}
|
||||||
|
displayName={displayName}
|
||||||
|
logoProvider={logoProvider}
|
||||||
|
data={data}
|
||||||
|
isLoading={isLoading}
|
||||||
|
hasChanges={hasChanges}
|
||||||
|
isRawJsonValid={isRawJsonValid}
|
||||||
|
isSaving={saveMutation.isPending}
|
||||||
|
onRefetch={refetch}
|
||||||
|
onSave={() => saveMutation.mutate()}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
|
||||||
|
<div className="flex flex-col overflow-hidden bg-muted/5">
|
||||||
|
<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">
|
||||||
|
<TabsContent
|
||||||
|
value="config"
|
||||||
|
className="flex-1 mt-0 border-0 p-0 data-[state=inactive]:hidden flex flex-col overflow-hidden"
|
||||||
|
>
|
||||||
|
<ModelConfigTab
|
||||||
|
catalog={catalog}
|
||||||
|
savedPresets={savedPresets}
|
||||||
|
currentModel={currentModel}
|
||||||
|
opusModel={opusModel}
|
||||||
|
sonnetModel={sonnetModel}
|
||||||
|
haikuModel={haikuModel}
|
||||||
|
providerModels={providerModels}
|
||||||
|
onApplyPreset={handleApplyPreset}
|
||||||
|
onUpdateEnvValue={updateEnvValue}
|
||||||
|
onOpenCustomPreset={() => setCustomPresetOpen(true)}
|
||||||
|
onDeletePreset={(name) =>
|
||||||
|
deletePresetMutation.mutate({ profile: provider, name })
|
||||||
|
}
|
||||||
|
isDeletePending={deletePresetMutation.isPending}
|
||||||
|
accounts={accounts}
|
||||||
|
onAddAccount={onAddAccount}
|
||||||
|
onSetDefault={onSetDefault}
|
||||||
|
onRemoveAccount={onRemoveAccount}
|
||||||
|
isRemovingAccount={isRemovingAccount}
|
||||||
|
privacyMode={privacyMode}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent
|
||||||
|
value="info"
|
||||||
|
className="h-full mt-0 border-0 p-0 data-[state=inactive]:hidden"
|
||||||
|
>
|
||||||
|
<ProviderInfoTab
|
||||||
|
provider={provider}
|
||||||
|
displayName={displayName}
|
||||||
|
data={data}
|
||||||
|
authStatus={authStatus}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<RawEditorSection
|
||||||
|
rawJsonContent={rawJsonContent}
|
||||||
|
isRawJsonValid={isRawJsonValid}
|
||||||
|
rawJsonEdits={rawJsonEdits}
|
||||||
|
onRawJsonChange={handleRawJsonChange}
|
||||||
|
profileEnv={data?.settings?.env}
|
||||||
|
/>
|
||||||
|
</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)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CustomPresetDialog
|
||||||
|
open={customPresetOpen}
|
||||||
|
onClose={() => setCustomPresetOpen(false)}
|
||||||
|
currentValues={{
|
||||||
|
default: currentModel || '',
|
||||||
|
opus: opusModel || '',
|
||||||
|
sonnet: sonnetModel || '',
|
||||||
|
haiku: haikuModel || '',
|
||||||
|
}}
|
||||||
|
onApply={handleCustomPresetApply}
|
||||||
|
onSave={handleCustomPresetSave}
|
||||||
|
isSaving={createPresetMutation.isPending}
|
||||||
|
catalog={catalog}
|
||||||
|
allModels={providerModels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { ProviderEditorProps, ModelMappingValues } from './types';
|
||||||
|
export { AccountItem } from './account-item';
|
||||||
|
export { UsageCommand } from './usage-command';
|
||||||
|
export { CustomPresetDialog } from './custom-preset-dialog';
|
||||||
|
export { ModelConfigSection } from './model-config-section';
|
||||||
|
export { RawEditorSection } from './raw-editor-section';
|
||||||
|
export { AccountsSection } from './accounts-section';
|
||||||
|
export { ProviderInfoTab } from './provider-info-tab';
|
||||||
|
export { ProviderEditorHeader } from './provider-editor-header';
|
||||||
|
export { ModelConfigTab } from './model-config-tab';
|
||||||
|
export { useProviderEditor } from './use-provider-editor';
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* Model Config Section
|
||||||
|
* Presets and model mapping configuration UI
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Sparkles, Zap, Star, X, Plus } from 'lucide-react';
|
||||||
|
import { FlexibleModelSelector } from '../provider-model-selector';
|
||||||
|
import type { ModelConfigSectionProps } from './types';
|
||||||
|
|
||||||
|
export function ModelConfigSection({
|
||||||
|
catalog,
|
||||||
|
savedPresets,
|
||||||
|
currentModel,
|
||||||
|
opusModel,
|
||||||
|
sonnetModel,
|
||||||
|
haikuModel,
|
||||||
|
providerModels,
|
||||||
|
onApplyPreset,
|
||||||
|
onUpdateEnvValue,
|
||||||
|
onOpenCustomPreset,
|
||||||
|
onDeletePreset,
|
||||||
|
isDeletePending,
|
||||||
|
}: ModelConfigSectionProps) {
|
||||||
|
const showPresets = (catalog && catalog.models.length > 0) || savedPresets.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Quick Presets */}
|
||||||
|
{showPresets && (
|
||||||
|
<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,
|
||||||
|
};
|
||||||
|
onApplyPreset({
|
||||||
|
ANTHROPIC_MODEL: mapping.default,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: mapping.opus,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: mapping.sonnet,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: mapping.haiku,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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={() => {
|
||||||
|
onApplyPreset({
|
||||||
|
ANTHROPIC_MODEL: preset.default,
|
||||||
|
ANTHROPIC_DEFAULT_OPUS_MODEL: preset.opus,
|
||||||
|
ANTHROPIC_DEFAULT_SONNET_MODEL: preset.sonnet,
|
||||||
|
ANTHROPIC_DEFAULT_HAIKU_MODEL: preset.haiku,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<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();
|
||||||
|
onDeletePreset(preset.name);
|
||||||
|
}}
|
||||||
|
disabled={isDeletePending}
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs h-7 gap-1 border-primary/50 text-primary hover:bg-primary/10 hover:border-primary"
|
||||||
|
onClick={onOpenCustomPreset}
|
||||||
|
>
|
||||||
|
<Plus className="w-3 h-3" />
|
||||||
|
Custom
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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) => onUpdateEnvValue('ANTHROPIC_MODEL', model)}
|
||||||
|
catalog={catalog}
|
||||||
|
allModels={providerModels}
|
||||||
|
/>
|
||||||
|
<FlexibleModelSelector
|
||||||
|
label="Opus (Most capable)"
|
||||||
|
description="For complex reasoning tasks"
|
||||||
|
value={opusModel}
|
||||||
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)}
|
||||||
|
catalog={catalog}
|
||||||
|
allModels={providerModels}
|
||||||
|
/>
|
||||||
|
<FlexibleModelSelector
|
||||||
|
label="Sonnet (Balanced)"
|
||||||
|
description="Balance of speed and capability"
|
||||||
|
value={sonnetModel}
|
||||||
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)}
|
||||||
|
catalog={catalog}
|
||||||
|
allModels={providerModels}
|
||||||
|
/>
|
||||||
|
<FlexibleModelSelector
|
||||||
|
label="Haiku (Fast)"
|
||||||
|
description="Quick responses for simple tasks"
|
||||||
|
value={haikuModel}
|
||||||
|
onChange={(model) => onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)}
|
||||||
|
catalog={catalog}
|
||||||
|
allModels={providerModels}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Model Config Tab
|
||||||
|
* Contains model config section and accounts section
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { ModelConfigSection } from './model-config-section';
|
||||||
|
import { AccountsSection } from './accounts-section';
|
||||||
|
import type { ProviderCatalog } from '../provider-model-selector';
|
||||||
|
import type { OAuthAccount } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface ModelConfigTabProps {
|
||||||
|
catalog?: ProviderCatalog;
|
||||||
|
savedPresets: Array<{
|
||||||
|
name: string;
|
||||||
|
default: string;
|
||||||
|
opus: string;
|
||||||
|
sonnet: string;
|
||||||
|
haiku: string;
|
||||||
|
}>;
|
||||||
|
currentModel?: string;
|
||||||
|
opusModel?: string;
|
||||||
|
sonnetModel?: string;
|
||||||
|
haikuModel?: string;
|
||||||
|
providerModels: Array<{ id: string; owned_by: string }>;
|
||||||
|
onApplyPreset: (updates: Record<string, string>) => void;
|
||||||
|
onUpdateEnvValue: (key: string, value: string) => void;
|
||||||
|
onOpenCustomPreset: () => void;
|
||||||
|
onDeletePreset: (name: string) => void;
|
||||||
|
isDeletePending?: boolean;
|
||||||
|
accounts: OAuthAccount[];
|
||||||
|
onAddAccount: () => void;
|
||||||
|
onSetDefault: (accountId: string) => void;
|
||||||
|
onRemoveAccount: (accountId: string) => void;
|
||||||
|
isRemovingAccount?: boolean;
|
||||||
|
privacyMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelConfigTab({
|
||||||
|
catalog,
|
||||||
|
savedPresets,
|
||||||
|
currentModel,
|
||||||
|
opusModel,
|
||||||
|
sonnetModel,
|
||||||
|
haikuModel,
|
||||||
|
providerModels,
|
||||||
|
onApplyPreset,
|
||||||
|
onUpdateEnvValue,
|
||||||
|
onOpenCustomPreset,
|
||||||
|
onDeletePreset,
|
||||||
|
isDeletePending,
|
||||||
|
accounts,
|
||||||
|
onAddAccount,
|
||||||
|
onSetDefault,
|
||||||
|
onRemoveAccount,
|
||||||
|
isRemovingAccount,
|
||||||
|
privacyMode,
|
||||||
|
}: ModelConfigTabProps) {
|
||||||
|
return (
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
<ModelConfigSection
|
||||||
|
catalog={catalog}
|
||||||
|
savedPresets={savedPresets}
|
||||||
|
currentModel={currentModel}
|
||||||
|
opusModel={opusModel}
|
||||||
|
sonnetModel={sonnetModel}
|
||||||
|
haikuModel={haikuModel}
|
||||||
|
providerModels={providerModels}
|
||||||
|
onApplyPreset={onApplyPreset}
|
||||||
|
onUpdateEnvValue={onUpdateEnvValue}
|
||||||
|
onOpenCustomPreset={onOpenCustomPreset}
|
||||||
|
onDeletePreset={onDeletePreset}
|
||||||
|
isDeletePending={isDeletePending}
|
||||||
|
/>
|
||||||
|
<Separator />
|
||||||
|
<AccountsSection
|
||||||
|
accounts={accounts}
|
||||||
|
onAddAccount={onAddAccount}
|
||||||
|
onSetDefault={onSetDefault}
|
||||||
|
onRemoveAccount={onRemoveAccount}
|
||||||
|
isRemovingAccount={isRemovingAccount}
|
||||||
|
privacyMode={privacyMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Provider Editor Header
|
||||||
|
* Header bar with provider info, refresh and save buttons
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Save, Loader2, RefreshCw } from 'lucide-react';
|
||||||
|
import { ProviderLogo } from '../provider-logo';
|
||||||
|
import type { SettingsResponse } from './types';
|
||||||
|
|
||||||
|
interface ProviderEditorHeaderProps {
|
||||||
|
provider: string;
|
||||||
|
displayName: string;
|
||||||
|
logoProvider?: string;
|
||||||
|
data?: SettingsResponse;
|
||||||
|
isLoading: boolean;
|
||||||
|
hasChanges: boolean;
|
||||||
|
isRawJsonValid: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
onRefetch: () => void;
|
||||||
|
onSave: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderEditorHeader({
|
||||||
|
displayName,
|
||||||
|
logoProvider,
|
||||||
|
provider,
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
hasChanges,
|
||||||
|
isRawJsonValid,
|
||||||
|
isSaving,
|
||||||
|
onRefetch,
|
||||||
|
onSave,
|
||||||
|
}: ProviderEditorHeaderProps) {
|
||||||
|
return (
|
||||||
|
<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?.path && (
|
||||||
|
<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={onRefetch} disabled={isLoading}>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onSave} disabled={isSaving || !hasChanges || !isRawJsonValid}>
|
||||||
|
{isSaving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-1" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/**
|
||||||
|
* Provider Info Tab
|
||||||
|
* Displays provider information and quick usage commands
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Info, Shield } from 'lucide-react';
|
||||||
|
import { UsageCommand } from './usage-command';
|
||||||
|
import type { SettingsResponse } from './types';
|
||||||
|
import type { AuthStatus } from '@/lib/api-client';
|
||||||
|
|
||||||
|
interface ProviderInfoTabProps {
|
||||||
|
provider: string;
|
||||||
|
displayName: string;
|
||||||
|
data?: SettingsResponse;
|
||||||
|
authStatus: AuthStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProviderInfoTab({ provider, displayName, data, authStatus }: ProviderInfoTabProps) {
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/**
|
||||||
|
* Raw Editor Section
|
||||||
|
* JSON editor panel with validation feedback
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { lazy, Suspense } from 'react';
|
||||||
|
import { Loader2, X } from 'lucide-react';
|
||||||
|
import { GlobalEnvIndicator } from '@/components/shared/global-env-indicator';
|
||||||
|
import type { RawEditorSectionProps } from './types';
|
||||||
|
|
||||||
|
// Lazy load CodeEditor
|
||||||
|
const CodeEditor = lazy(() =>
|
||||||
|
import('@/components/shared/code-editor').then((m) => ({ default: m.CodeEditor }))
|
||||||
|
);
|
||||||
|
|
||||||
|
export function RawEditorSection({
|
||||||
|
rawJsonContent,
|
||||||
|
isRawJsonValid,
|
||||||
|
rawJsonEdits,
|
||||||
|
onRawJsonChange,
|
||||||
|
profileEnv,
|
||||||
|
}: RawEditorSectionProps) {
|
||||||
|
return (
|
||||||
|
<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-4 pt-4">
|
||||||
|
<div className="h-full border rounded-md overflow-hidden bg-background">
|
||||||
|
<CodeEditor
|
||||||
|
value={rawJsonContent}
|
||||||
|
onChange={onRawJsonChange}
|
||||||
|
language="json"
|
||||||
|
minHeight="100%"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Global Env Indicator */}
|
||||||
|
<div className="mx-6 mb-4">
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<GlobalEnvIndicator profileEnv={profileEnv} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* Type definitions for ProviderEditor components
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||||
|
import type { ProviderCatalog } from '../provider-model-selector';
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
profile: string;
|
||||||
|
settings: {
|
||||||
|
env?: Record<string, string>;
|
||||||
|
};
|
||||||
|
mtime: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export 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 interface AccountItemProps {
|
||||||
|
account: OAuthAccount;
|
||||||
|
onSetDefault: () => void;
|
||||||
|
onRemove: () => void;
|
||||||
|
isRemoving?: boolean;
|
||||||
|
privacyMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelMappingValues {
|
||||||
|
default: string;
|
||||||
|
opus: string;
|
||||||
|
sonnet: string;
|
||||||
|
haiku: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomPresetDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
currentValues: ModelMappingValues;
|
||||||
|
onApply: (values: ModelMappingValues, presetName?: string) => void;
|
||||||
|
onSave?: (values: ModelMappingValues, presetName?: string) => void;
|
||||||
|
isSaving?: boolean;
|
||||||
|
catalog?: ProviderCatalog;
|
||||||
|
allModels: { id: string; owned_by: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawEditorSectionProps {
|
||||||
|
rawJsonContent: string;
|
||||||
|
isRawJsonValid: boolean;
|
||||||
|
rawJsonEdits: string | null;
|
||||||
|
onRawJsonChange: (value: string) => void;
|
||||||
|
profileEnv?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ModelConfigSectionProps {
|
||||||
|
catalog?: ProviderCatalog;
|
||||||
|
savedPresets: Array<{
|
||||||
|
name: string;
|
||||||
|
default: string;
|
||||||
|
opus: string;
|
||||||
|
sonnet: string;
|
||||||
|
haiku: string;
|
||||||
|
}>;
|
||||||
|
currentModel?: string;
|
||||||
|
opusModel?: string;
|
||||||
|
sonnetModel?: string;
|
||||||
|
haikuModel?: string;
|
||||||
|
providerModels: Array<{ id: string; owned_by: string }>;
|
||||||
|
onApplyPreset: (updates: Record<string, string>) => void;
|
||||||
|
onUpdateEnvValue: (key: string, value: string) => void;
|
||||||
|
onOpenCustomPreset: () => void;
|
||||||
|
onDeletePreset: (name: string) => void;
|
||||||
|
isDeletePending?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseProviderEditorReturn {
|
||||||
|
data: SettingsResponse | undefined;
|
||||||
|
isLoading: boolean;
|
||||||
|
refetch: () => void;
|
||||||
|
rawJsonContent: string;
|
||||||
|
rawJsonEdits: string | null;
|
||||||
|
isRawJsonValid: boolean;
|
||||||
|
hasChanges: boolean;
|
||||||
|
currentSettings: { env?: Record<string, string> };
|
||||||
|
currentModel?: string;
|
||||||
|
opusModel?: string;
|
||||||
|
sonnetModel?: string;
|
||||||
|
haikuModel?: string;
|
||||||
|
handleRawJsonChange: (value: string) => void;
|
||||||
|
updateEnvValue: (key: string, value: string) => void;
|
||||||
|
updateEnvValues: (updates: Record<string, string>) => void;
|
||||||
|
saveMutation: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
conflictDialog: boolean;
|
||||||
|
setConflictDialog: (open: boolean) => void;
|
||||||
|
handleConflictResolve: (overwrite: boolean) => Promise<void>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* Usage Command Component
|
||||||
|
* Displays a CLI command with copy button
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CopyButton } from '@/components/ui/copy-button';
|
||||||
|
|
||||||
|
interface UsageCommandProps {
|
||||||
|
label: string;
|
||||||
|
command: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UsageCommand({ label, command }: UsageCommandProps) {
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* useProviderEditor Hook
|
||||||
|
* Manages query, mutation, and state logic for ProviderEditor
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useMemo, useCallback } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { SettingsResponse, UseProviderEditorReturn } from './types';
|
||||||
|
|
||||||
|
export function useProviderEditor(provider: string): UseProviderEditorReturn {
|
||||||
|
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
|
||||||
|
const [conflictDialog, setConflictDialog] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
// 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 single setting value
|
||||||
|
const updateEnvValue = useCallback(
|
||||||
|
(key: string, value: string) => {
|
||||||
|
const newEnv = { ...(currentSettings?.env || {}), [key]: value };
|
||||||
|
const newSettings = { ...currentSettings, env: newEnv };
|
||||||
|
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||||
|
},
|
||||||
|
[currentSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Batch update multiple env values at once
|
||||||
|
const updateEnvValues = useCallback(
|
||||||
|
(updates: Record<string, string>) => {
|
||||||
|
const newEnv = { ...(currentSettings?.env || {}), ...updates };
|
||||||
|
const newSettings = { ...currentSettings, env: newEnv };
|
||||||
|
setRawJsonEdits(JSON.stringify(newSettings, null, 2));
|
||||||
|
},
|
||||||
|
[currentSettings]
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
data,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
rawJsonContent,
|
||||||
|
rawJsonEdits,
|
||||||
|
isRawJsonValid,
|
||||||
|
hasChanges,
|
||||||
|
currentSettings,
|
||||||
|
currentModel,
|
||||||
|
opusModel,
|
||||||
|
sonnetModel,
|
||||||
|
haikuModel,
|
||||||
|
handleRawJsonChange,
|
||||||
|
updateEnvValue,
|
||||||
|
updateEnvValues,
|
||||||
|
saveMutation: {
|
||||||
|
mutate: () => saveMutation.mutate(),
|
||||||
|
isPending: saveMutation.isPending,
|
||||||
|
},
|
||||||
|
conflictDialog,
|
||||||
|
setConflictDialog,
|
||||||
|
handleConflictResolve,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user