mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 20:17:45 +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
|
||||
* Split-view editor for CLIProxy provider settings
|
||||
* Similar to ProfileEditor but tailored for provider configuration
|
||||
* Re-exports from modular directory for backward compatibility
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, lazy, Suspense } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ConfirmDialog } from '@/components/confirm-dialog';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Save,
|
||||
Loader2,
|
||||
Code2,
|
||||
Trash2,
|
||||
RefreshCw,
|
||||
Info,
|
||||
X,
|
||||
Shield,
|
||||
User,
|
||||
Plus,
|
||||
Star,
|
||||
MoreHorizontal,
|
||||
Clock,
|
||||
Sparkles,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { toast } from 'sonner';
|
||||
import { ProviderLogo } from './provider-logo';
|
||||
import { FlexibleModelSelector } from './provider-model-selector';
|
||||
import type { ProviderCatalog } from './provider-model-selector';
|
||||
import type { AuthStatus, OAuthAccount } from '@/lib/api-client';
|
||||
import {
|
||||
useCliproxyModels,
|
||||
usePresets,
|
||||
useCreatePreset,
|
||||
useDeletePreset,
|
||||
} from '@/hooks/use-cliproxy';
|
||||
import { cn } from '@/lib/utils';
|
||||
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>
|
||||
);
|
||||
}
|
||||
export { ProviderEditor } from './provider-editor/index';
|
||||
export type { ProviderEditorProps, ModelMappingValues } from './provider-editor/types';
|
||||
|
||||
@@ -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