diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx new file mode 100644 index 00000000..f699305d --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -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; + +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({ + 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 ( + + + + Create CLIProxy Variant + +
+
+ + + {errors.name && {errors.name.message}} +
+ +
+ + + {errors.provider && ( + {errors.provider.message} + )} +
+ + {/* Account selector - only show if provider has accounts */} + {selectedProvider && providerAccounts.length > 0 && ( +
+ + + + Select which OAuth account this variant should use + +
+ )} + + {/* Show message if provider selected but no accounts */} + {selectedProvider && providerAccounts.length === 0 && providerAuth && ( +
+ No accounts authenticated for {providerAuth.displayName}. +
+ ccs {selectedProvider} --auth +
+ )} + +
+ + +
+ +
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/cliproxy-stats-overview.tsx b/ui/src/components/cliproxy/cliproxy-stats-overview.tsx new file mode 100644 index 00000000..0e871eb9 --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-stats-overview.tsx @@ -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 ( +
+
+
+ + +
+ +
+
+ {[1, 2, 3, 4].map((i) => ( + + ))} +
+
+ ); + } + + // Offline state + if (!status?.running) { + return ( +
+
+
+

+ + Session Statistics +

+

+ Real-time usage metrics from CLIProxyAPI +

+
+ + + Offline + +
+ + + +
+ +
+

No Active Session

+

+ Start a CLIProxy session using{' '} + ccs gemini,{' '} + ccs codex, + or ccs agy{' '} + to view real-time statistics. +

+
+
+
+ ); + } + + // Error state + if (error) { + return ( +
+ + + +
+

Failed to Load Statistics

+

{error.message}

+
+
+
+
+ ); + } + + // 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 ( +
+ {/* Header */} +
+
+

+ + Session Statistics +

+

Real-time usage metrics from CLIProxyAPI

+
+ + + Running + +
+ + {/* Stats Grid */} +
+ {/* Requests Card */} + + +
+
+

Total Requests

+

{formatNumber(totalRequests)}

+
+ + {successRequests} success +
+
+
+ +
+
+
+
+ + {/* Success Rate Card */} + + +
+
+

Success Rate

+

{successRate}%

+
+
= 90 ? 'bg-green-500' : 'bg-amber-500' + )} + style={{ width: `${successRate}%` }} + /> +
+
+
= 90 + ? 'bg-green-100 dark:bg-green-900/20' + : 'bg-amber-100 dark:bg-amber-900/20' + )} + > + {successRate >= 90 ? ( + + ) : ( + + )} +
+
+ + + + {/* Tokens Card */} + + +
+
+

Total Tokens

+

+ {formatNumber(totalTokens)} +

+

+ ~${estimateCost(totalTokens).toFixed(2)} estimated +

+
+
+ +
+
+
+
+ + {/* Models Card */} + + +
+
+

Models Used

+

{models.length}

+

+ {models.length > 0 ? formatModelName(models[0][0]) : 'None'} +

+
+
+ +
+
+
+
+
+ + {/* Model Breakdown */} + {models.length > 0 && ( + + + + + Model Usage Distribution + + + +
+ {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 ( +
+
+
+
+ + {displayName} + +
+
+ {count} requests + {percentage}% +
+
+
+
+
+
+ ); + })} +
+ + + )} +
+ ); +} + +/** 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; +} diff --git a/ui/src/components/cliproxy/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx new file mode 100644 index 00000000..b9f7e2bb --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-table.tsx @@ -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 = { + gemini: 'Google Gemini', + codex: 'OpenAI Codex', + agy: 'Antigravity', + qwen: 'Alibaba Qwen', + iflow: 'iFlow', +}; + +export function CliproxyTable({ data }: CliproxyTableProps) { + const deleteMutation = useDeleteVariant(); + + const columns: ColumnDef[] = [ + { + accessorKey: 'name', + header: 'Name', + cell: ({ row }) => {row.original.name}, + }, + { + 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 default; + } + return ( + + + {account} + + ); + }, + }, + { + accessorKey: 'settings', + header: 'Settings Path', + cell: ({ row }) => ( + + {row.original.settings || `config.cliproxy.${row.original.name}`} + + ), + }, + { + id: 'actions', + header: 'Actions', + cell: ({ row }) => ( +
+ + + + + + deleteMutation.mutate(row.original.name)} + > + + Delete + + + +
+ ), + }, + ]; + + // eslint-disable-next-line react-hooks/incompatible-library + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + + if (data.length === 0) { + return ( +
+
No CLIProxy variants found.
+
+ Create one to use OAuth-based providers with specific account configurations. +
+
+ ); + } + + return ( +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/index.ts b/ui/src/components/cliproxy/index.ts new file mode 100644 index 00000000..5c743916 --- /dev/null +++ b/ui/src/components/cliproxy/index.ts @@ -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'; diff --git a/ui/src/components/cliproxy/provider-editor.tsx b/ui/src/components/cliproxy/provider-editor.tsx index 0d633340..ce016b03 100644 --- a/ui/src/components/cliproxy/provider-editor.tsx +++ b/ui/src/components/cliproxy/provider-editor.tsx @@ -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; - }; - 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(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 = { - 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({ - 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) => { - 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 = () => ( -
- -
- - - Model Config - - - Info & Usage - - -
- -
- {/* Model Config Tab */} - - -
- {/* Quick Presets */} - {(catalog && catalog.models.length > 0) || savedPresets.length > 0 ? ( -
-

- - Presets -

-

- Apply pre-configured model mappings -

-
- {/* Recommended presets from catalog */} - {catalog?.models.slice(0, 3).map((model) => ( - - ))} - - {/* User saved presets */} - {savedPresets.map((preset) => ( -
- - -
- ))} - - -
-
- ) : null} - - - - {/* Model Mapping */} -
-

Model Mapping

-

- Configure which models to use for each tier -

-
- updateEnvValue('ANTHROPIC_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> - updateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> - updateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> - updateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)} - catalog={catalog} - allModels={providerModels} - /> -
-
- - - - {/* Accounts Section */} -
-
-

- - Accounts - {accounts.length > 0 && ( - - {accounts.length} - - )} -

- -
- - {accounts.length > 0 ? ( -
- {accounts.map((account) => ( - onSetDefault(account.id)} - onRemove={() => onRemoveAccount(account.id)} - isRemoving={isRemovingAccount} - privacyMode={privacyMode} - /> - ))} -
- ) : ( -
- -

No accounts connected

-

Add an account to get started

-
- )} -
-
-
-
- - {/* Info Tab */} - - -
- {/* Provider Information */} -
-

- - Provider Information -

-
-
- Provider - {displayName} -
- {data && ( - <> -
- File Path -
- - {data.path} - - -
-
-
- Last Modified - {new Date(data.mtime).toLocaleString()} -
- - )} -
- Status - {authStatus.authenticated ? ( - - - Authenticated - - ) : ( - - Not connected - - )} -
-
-
- - {/* Quick Usage */} -
-

Quick Usage

-
- - - - -
-
-
-
-
-
-
-
- ); - - // Render Right Column - Raw JSON Editor - const renderRawEditor = () => ( - - - Loading editor... -
- } - > -
- {!isRawJsonValid && rawJsonEdits !== null && ( -
- - Invalid JSON syntax -
- )} -
-
- -
-
- {/* Global Env Indicator */} -
-
- -
-
-
- - ); - - return ( -
- {/* Header */} -
-
- -
-
-

{displayName}

- {data?.path && ( - - {data.path.replace(/^.*\//, '')} - - )} -
- {data && ( -

- Last modified: {new Date(data.mtime).toLocaleString()} -

- )} -
-
-
- - -
-
- - {isLoading ? ( -
- - Loading settings... -
- ) : ( - // Split Layout (40% Left / 60% Right) -
- {/* Left Column: Friendly UI */} -
{renderFriendlyUI()}
- - {/* Right Column: Raw Editor */} -
-
- - - Raw Configuration (JSON) - -
- {renderRawEditor()} -
-
- )} - - handleConflictResolve(true)} - onCancel={() => handleConflictResolve(false)} - /> - - {/* Custom Preset Dialog */} - 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} - /> -
- ); -} - -/** Account item component */ -function AccountItem({ - account, - onSetDefault, - onRemove, - isRemoving, - privacyMode, -}: { - account: OAuthAccount; - onSetDefault: () => void; - onRemove: () => void; - isRemoving?: boolean; - privacyMode?: boolean; -}) { - return ( -
-
-
- -
-
-
- - {account.email || account.id} - - {account.isDefault && ( - - - Default - - )} -
- {account.lastUsedAt && ( -
- - Last used: {new Date(account.lastUsedAt).toLocaleDateString()} -
- )} -
-
- - - - - - - {!account.isDefault && ( - - - Set as default - - )} - - - {isRemoving ? 'Removing...' : 'Remove account'} - - - -
- ); -} - -/** Usage command with copy button */ -function UsageCommand({ label, command }: { label: string; command: string }) { - return ( -
- -
- - {command} - - -
-
- ); -} - -/** 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 ( - - - - - - Custom Preset - - -
-
- - setPresetName(e.target.value)} - placeholder="e.g., My Custom Config" - className="text-sm" - /> -
- - setValues({ ...values, default: model })} - catalog={catalog} - allModels={allModels} - /> - setValues({ ...values, opus: model })} - catalog={catalog} - allModels={allModels} - /> - setValues({ ...values, sonnet: model })} - catalog={catalog} - allModels={allModels} - /> - setValues({ ...values, haiku: model })} - catalog={catalog} - allModels={allModels} - /> -
- - - {onSave && ( - - )} - - -
-
- ); -} +export { ProviderEditor } from './provider-editor/index'; +export type { ProviderEditorProps, ModelMappingValues } from './provider-editor/types'; diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx new file mode 100644 index 00000000..589353ee --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -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 ( +
+
+
+ +
+
+
+ + {account.email || account.id} + + {account.isDefault && ( + + + Default + + )} +
+ {account.lastUsedAt && ( +
+ + Last used: {new Date(account.lastUsedAt).toLocaleDateString()} +
+ )} +
+
+ + + + + + + {!account.isDefault && ( + + + Set as default + + )} + + + {isRemoving ? 'Removing...' : 'Remove account'} + + + +
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx new file mode 100644 index 00000000..7d398ac3 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -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 ( +
+
+

+ + Accounts + {accounts.length > 0 && ( + + {accounts.length} + + )} +

+ +
+ + {accounts.length > 0 ? ( +
+ {accounts.map((account) => ( + onSetDefault(account.id)} + onRemove={() => onRemoveAccount(account.id)} + isRemoving={isRemovingAccount} + privacyMode={privacyMode} + /> + ))} +
+ ) : ( +
+ +

No accounts connected

+

Add an account to get started

+
+ )} +
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx new file mode 100644 index 00000000..a078dd6b --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/custom-preset-dialog.tsx @@ -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(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 ( + + + + + + Custom Preset + + +
+
+ + setPresetName(e.target.value)} + placeholder="e.g., My Custom Config" + className="text-sm" + /> +
+ + setValues({ ...values, default: model })} + catalog={catalog} + allModels={allModels} + /> + setValues({ ...values, opus: model })} + catalog={catalog} + allModels={allModels} + /> + setValues({ ...values, sonnet: model })} + catalog={catalog} + allModels={allModels} + /> + setValues({ ...values, haiku: model })} + catalog={catalog} + allModels={allModels} + /> +
+ + + {onSave && ( + + )} + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx new file mode 100644 index 00000000..2d9f14ff --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -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 = { + 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) => { + 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 ( +
+ saveMutation.mutate()} + /> + + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( +
+
+ +
+ + + Model Config + + + Info & Usage + + +
+
+ + setCustomPresetOpen(true)} + onDeletePreset={(name) => + deletePresetMutation.mutate({ profile: provider, name }) + } + isDeletePending={deletePresetMutation.isPending} + accounts={accounts} + onAddAccount={onAddAccount} + onSetDefault={onSetDefault} + onRemoveAccount={onRemoveAccount} + isRemovingAccount={isRemovingAccount} + privacyMode={privacyMode} + /> + + + + +
+
+
+ +
+
+ + + Raw Configuration (JSON) + +
+ +
+
+ )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> + + setCustomPresetOpen(false)} + currentValues={{ + default: currentModel || '', + opus: opusModel || '', + sonnet: sonnetModel || '', + haiku: haikuModel || '', + }} + onApply={handleCustomPresetApply} + onSave={handleCustomPresetSave} + isSaving={createPresetMutation.isPending} + catalog={catalog} + allModels={providerModels} + /> +
+ ); +} + +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'; diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx new file mode 100644 index 00000000..ac43a978 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -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 && ( +
+

+ + Presets +

+

Apply pre-configured model mappings

+
+ {/* Recommended presets from catalog */} + {catalog?.models.slice(0, 3).map((model) => ( + + ))} + + {/* User saved presets */} + {savedPresets.map((preset) => ( +
+ + +
+ ))} + + +
+
+ )} + + + + {/* Model Mapping */} +
+

Model Mapping

+

+ Configure which models to use for each tier +

+
+ onUpdateEnvValue('ANTHROPIC_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> + onUpdateEnvValue('ANTHROPIC_DEFAULT_OPUS_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> + onUpdateEnvValue('ANTHROPIC_DEFAULT_SONNET_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> + onUpdateEnvValue('ANTHROPIC_DEFAULT_HAIKU_MODEL', model)} + catalog={catalog} + allModels={providerModels} + /> +
+
+ + ); +} diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx new file mode 100644 index 00000000..c6f286a8 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -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) => 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 ( + +
+ + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx new file mode 100644 index 00000000..f64f9605 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/provider-editor-header.tsx @@ -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 ( +
+
+ +
+
+

{displayName}

+ {data?.path && ( + + {data.path.replace(/^.*\//, '')} + + )} +
+ {data && ( +

+ Last modified: {new Date(data.mtime).toLocaleString()} +

+ )} +
+
+
+ + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx new file mode 100644 index 00000000..20d9e40f --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/provider-info-tab.tsx @@ -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 ( + +
+ {/* Provider Information */} +
+

+ + Provider Information +

+
+
+ Provider + {displayName} +
+ {data && ( + <> +
+ File Path +
+ + {data.path} + + +
+
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+ + )} +
+ Status + {authStatus.authenticated ? ( + + + Authenticated + + ) : ( + + Not connected + + )} +
+
+
+ + {/* Quick Usage */} +
+

Quick Usage

+
+ + + + +
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx new file mode 100644 index 00000000..4150fd43 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/raw-editor-section.tsx @@ -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 ( + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+
+ +
+
+ {/* Global Env Indicator */} +
+
+ +
+
+
+ + ); +} diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts new file mode 100644 index 00000000..4d1d87a6 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -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; + }; + 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; +} + +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) => 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 }; + currentModel?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + handleRawJsonChange: (value: string) => void; + updateEnvValue: (key: string, value: string) => void; + updateEnvValues: (updates: Record) => void; + saveMutation: { + mutate: () => void; + isPending: boolean; + }; + conflictDialog: boolean; + setConflictDialog: (open: boolean) => void; + handleConflictResolve: (overwrite: boolean) => Promise; +} diff --git a/ui/src/components/cliproxy/provider-editor/usage-command.tsx b/ui/src/components/cliproxy/provider-editor/usage-command.tsx new file mode 100644 index 00000000..f720cbf8 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/usage-command.tsx @@ -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 ( +
+ +
+ + {command} + + +
+
+ ); +} diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts new file mode 100644 index 00000000..7968a5b2 --- /dev/null +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -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(null); + const [conflictDialog, setConflictDialog] = useState(false); + const queryClient = useQueryClient(); + + // Fetch settings for this provider + const { data, isLoading, refetch } = useQuery({ + 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) => { + 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, + }; +}