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:
kaitranntt
2025-12-19 19:39:32 -05:00
parent 8fd35c8dd6
commit 4bea5a3346
17 changed files with 1987 additions and 917 deletions
@@ -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>
);
}
+29
View File
@@ -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';
+3 -917
View File
@@ -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,
};
}