From 83570050ef9b68746405df8588e400faa2007c0a Mon Sep 17 00:00:00 2001 From: kaitranntt Date: Wed, 10 Dec 2025 21:36:11 -0500 Subject: [PATCH] feat(api-profile-ux): Implement API & UI for profile management --- src/commands/api-command.ts | 47 +- src/web-server/routes.ts | 71 ++- ui/src/components/profile-create-form.tsx | 319 +++++++++++++ ui/src/components/profile-dialog.tsx | 103 ++++- ui/src/components/profile-editor.tsx | 528 ++++++++++++++++++++++ ui/src/lib/api-client.ts | 6 + ui/src/pages/api.tsx | 351 ++++++++++++-- 7 files changed, 1359 insertions(+), 66 deletions(-) create mode 100644 ui/src/components/profile-create-form.tsx create mode 100644 ui/src/components/profile-editor.tsx diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 71a5468a..a541fdf5 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -370,32 +370,53 @@ async function handleCreate(args: string[]): Promise { const defaultModel = 'claude-sonnet-4-5-20250929'; let model = parsedArgs.model; if (!model && !parsedArgs.yes) { - model = await InteractivePrompt.input('Default model', { + model = await InteractivePrompt.input('Default model (ANTHROPIC_MODEL)', { default: defaultModel, }); } model = model || defaultModel; - // Step 5: Optional model mapping for Opus/Sonnet/Haiku - // Ask user if they want different models for each type + // Step 5: Model mapping for Opus/Sonnet/Haiku + // Auto-show if user entered a custom model, otherwise ask let opusModel = model; let sonnetModel = model; let haikuModel = model; + const isCustomModel = model !== defaultModel; + if (!parsedArgs.yes) { - console.log(''); - console.log(dim('Some API proxies route different model types to different backends.')); - const wantCustomMapping = await InteractivePrompt.confirm( - 'Configure different models for Opus/Sonnet/Haiku?', - { default: false } - ); + // If user entered custom model, auto-prompt for model mapping + // Otherwise, ask if they want to configure it + let wantCustomMapping = isCustomModel; + + if (!isCustomModel) { + console.log(''); + console.log(dim('Some API proxies route different model types to different backends.')); + wantCustomMapping = await InteractivePrompt.confirm( + 'Configure different models for Opus/Sonnet/Haiku?', + { default: false } + ); + } if (wantCustomMapping) { console.log(''); - console.log(dim('Leave blank to use the default model for each.')); - opusModel = (await InteractivePrompt.input('Opus model', { default: model })) || model; - sonnetModel = (await InteractivePrompt.input('Sonnet model', { default: model })) || model; - haikuModel = (await InteractivePrompt.input('Haiku model', { default: model })) || model; + if (isCustomModel) { + console.log(dim('Configure model IDs for each tier (defaults to your model):')); + } else { + console.log(dim('Leave blank to use the default model for each.')); + } + opusModel = + (await InteractivePrompt.input('Opus model (ANTHROPIC_DEFAULT_OPUS_MODEL)', { + default: model, + })) || model; + sonnetModel = + (await InteractivePrompt.input('Sonnet model (ANTHROPIC_DEFAULT_SONNET_MODEL)', { + default: model, + })) || model; + haikuModel = + (await InteractivePrompt.input('Haiku model (ANTHROPIC_DEFAULT_HAIKU_MODEL)', { + default: model, + })) || model; } } diff --git a/src/web-server/routes.ts b/src/web-server/routes.ts index 956cae88..c5a16511 100644 --- a/src/web-server/routes.ts +++ b/src/web-server/routes.ts @@ -77,17 +77,34 @@ function isConfigured(profileName: string, config: Config): boolean { } } +/** Model mapping for API profiles */ +interface ModelMapping { + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; +} + /** * Helper: Create settings file for profile */ -function createSettingsFile(name: string, baseUrl: string, apiKey: string, model?: string): string { +function createSettingsFile( + name: string, + baseUrl: string, + apiKey: string, + models: ModelMapping = {} +): string { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); + const { model, opusModel, sonnetModel, haikuModel } = models; const settings: Settings = { env: { ANTHROPIC_BASE_URL: baseUrl, ANTHROPIC_AUTH_TOKEN: apiKey, ...(model && { ANTHROPIC_MODEL: model }), + ...(opusModel && { ANTHROPIC_DEFAULT_OPUS_MODEL: opusModel }), + ...(sonnetModel && { ANTHROPIC_DEFAULT_SONNET_MODEL: sonnetModel }), + ...(haikuModel && { ANTHROPIC_DEFAULT_HAIKU_MODEL: haikuModel }), }, }; @@ -100,7 +117,14 @@ function createSettingsFile(name: string, baseUrl: string, apiKey: string, model */ function updateSettingsFile( name: string, - updates: { baseUrl?: string; apiKey?: string; model?: string } + updates: { + baseUrl?: string; + apiKey?: string; + model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; + } ): void { const settingsPath = path.join(getCcsDir(), `${name}.settings.json`); @@ -129,6 +153,34 @@ function updateSettingsFile( } } + // Handle model mapping fields + if (updates.opusModel !== undefined) { + settings.env = settings.env || {}; + if (updates.opusModel) { + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = updates.opusModel; + } else { + delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL; + } + } + + if (updates.sonnetModel !== undefined) { + settings.env = settings.env || {}; + if (updates.sonnetModel) { + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = updates.sonnetModel; + } else { + delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL; + } + } + + if (updates.haikuModel !== undefined) { + settings.env = settings.env || {}; + if (updates.haikuModel) { + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = updates.haikuModel; + } else { + delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL; + } + } + fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n'); } @@ -166,7 +218,7 @@ apiRoutes.get('/profiles', (_req: Request, res: Response) => { * POST /api/profiles - Create new profile */ apiRoutes.post('/profiles', (req: Request, res: Response): void => { - const { name, baseUrl, apiKey, model } = req.body; + const { name, baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; if (!name || !baseUrl || !apiKey) { res.status(400).json({ error: 'Missing required fields: name, baseUrl, apiKey' }); @@ -185,8 +237,13 @@ apiRoutes.post('/profiles', (req: Request, res: Response): void => { fs.mkdirSync(getCcsDir(), { recursive: true }); } - // Create settings file - const settingsPath = createSettingsFile(name, baseUrl, apiKey, model); + // Create settings file with model mapping + const settingsPath = createSettingsFile(name, baseUrl, apiKey, { + model, + opusModel, + sonnetModel, + haikuModel, + }); // Update config config.profiles[name] = settingsPath; @@ -200,7 +257,7 @@ apiRoutes.post('/profiles', (req: Request, res: Response): void => { */ apiRoutes.put('/profiles/:name', (req: Request, res: Response): void => { const { name } = req.params; - const { baseUrl, apiKey, model } = req.body; + const { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel } = req.body; const config = readConfigSafe(); @@ -210,7 +267,7 @@ apiRoutes.put('/profiles/:name', (req: Request, res: Response): void => { } try { - updateSettingsFile(name, { baseUrl, apiKey, model }); + updateSettingsFile(name, { baseUrl, apiKey, model, opusModel, sonnetModel, haikuModel }); res.json({ name, updated: true }); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/ui/src/components/profile-create-form.tsx b/ui/src/components/profile-create-form.tsx new file mode 100644 index 00000000..8674ecf0 --- /dev/null +++ b/ui/src/components/profile-create-form.tsx @@ -0,0 +1,319 @@ +/** + * Profile Create Form Component + * Inline form for creating new API profiles with model mapping + */ + +import { useState, useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { useCreateProfile } from '@/hooks/use-profiles'; +import { + ArrowLeft, + Loader2, + Plus, + ChevronDown, + ChevronRight, + AlertTriangle, + HelpCircle, +} from 'lucide-react'; +import { toast } from 'sonner'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; + +const schema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Must start with letter, only letters/numbers/.-_'), + baseUrl: z.string().url('Invalid URL format'), + apiKey: z.string().min(10, 'API key must be at least 10 characters'), + model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), +}); + +type FormData = z.infer; + +interface ProfileCreateFormProps { + onSuccess: (name: string) => void; + onCancel: () => void; +} + +// Common URL mistakes to warn about +const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions']; + +export function ProfileCreateForm({ onSuccess, onCancel }: ProfileCreateFormProps) { + const createMutation = useCreateProfile(); + const [showModelMapping, setShowModelMapping] = useState(false); + const [urlWarning, setUrlWarning] = useState(null); + + const { + register, + handleSubmit, + formState: { errors }, + watch, + } = useForm({ + resolver: zodResolver(schema), + defaultValues: { + model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', + }, + }); + + const modelValue = watch('model'); + const baseUrlValue = watch('baseUrl'); + + // Auto-expand model mapping when custom model is entered + useEffect(() => { + if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { + setShowModelMapping(true); + } + }, [modelValue]); + + // Check for common URL mistakes + useEffect(() => { + if (baseUrlValue) { + const lowerUrl = baseUrlValue.toLowerCase(); + for (const path of PROBLEMATIC_PATHS) { + if (lowerUrl.endsWith(path)) { + const suggestedUrl = baseUrlValue.replace(new RegExp(path + '$', 'i'), ''); + setUrlWarning( + `URL ends with "${path}" - Claude appends this automatically. You likely want: ${suggestedUrl}` + ); + return; + } + } + } + setUrlWarning(null); + }, [baseUrlValue]); + + const onSubmit = async (data: FormData) => { + try { + await createMutation.mutateAsync(data); + toast.success(`Profile "${data.name}" created`); + onSuccess(data.name); + } catch (error) { + toast.error((error as Error).message || 'Failed to create profile'); + } + }; + + return ( +
+ {/* Header */} +
+ +
+

Create API Profile

+

Configure a new custom API endpoint

+
+
+ + +
+
+ {/* Basic Info Card */} + + + Basic Information + Profile name and API connection details + + + {/* Name */} +
+ + + {errors.name ? ( +

{errors.name.message}

+ ) : ( +

+ Used as: ccs my-api "prompt" +

+ )} +
+ + {/* Base URL */} +
+ + + {errors.baseUrl ? ( +

{errors.baseUrl.message}

+ ) : urlWarning ? ( +
+ + {urlWarning} +
+ ) : ( +

+ Base URL without /chat/completions (Claude adds this) +

+ )} +
+ + {/* API Key */} +
+ + + {errors.apiKey && ( +

{errors.apiKey.message}

+ )} +
+
+
+ + {/* Model Configuration Card */} + + + Model Configuration + Configure which models to use with this API + + + {/* Default Model */} +
+ + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Expander */} +
+ + + {showModelMapping && ( +
+
+ + + Configure different model IDs for each tier. Useful for API proxies that + route Opus/Sonnet/Haiku to different backends. + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+
+ + {/* Actions */} +
+ + +
+
+
+
+
+ ); +} diff --git a/ui/src/components/profile-dialog.tsx b/ui/src/components/profile-dialog.tsx index 3734c5ce..183f2f24 100644 --- a/ui/src/components/profile-dialog.tsx +++ b/ui/src/components/profile-dialog.tsx @@ -1,8 +1,10 @@ /** * Profile Dialog Component * Phase 03: REST API Routes & CRUD + * Updated: Added model mapping fields for Opus/Sonnet/Haiku */ +import { useState, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; @@ -12,6 +14,9 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useCreateProfile, useUpdateProfile } from '@/hooks/use-profiles'; import type { Profile } from '@/lib/api-client'; +import { ChevronDown, ChevronRight } from 'lucide-react'; + +const DEFAULT_MODEL = 'claude-sonnet-4-5-20250929'; const schema = z.object({ name: z @@ -21,6 +26,9 @@ const schema = z.object({ baseUrl: z.string().url('Invalid URL'), apiKey: z.string().min(10, 'API key must be at least 10 characters'), model: z.string().optional(), + opusModel: z.string().optional(), + sonnetModel: z.string().optional(), + haikuModel: z.string().optional(), }); type FormData = z.infer; @@ -34,12 +42,14 @@ interface ProfileDialogProps { export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { const createMutation = useCreateProfile(); const updateMutation = useUpdateProfile(); + const [showModelMapping, setShowModelMapping] = useState(false); const { register, handleSubmit, formState: { errors }, reset, + watch, } = useForm({ resolver: zodResolver(schema), defaultValues: profile @@ -48,10 +58,30 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: '', apiKey: '', model: '', + opusModel: '', + sonnetModel: '', + haikuModel: '', } : undefined, }); + // Watch model field to auto-expand model mapping when custom model is entered + const modelValue = watch('model'); + + useEffect(() => { + // Auto-show model mapping if user enters a custom model (not default) + if (modelValue && modelValue !== DEFAULT_MODEL && modelValue.trim() !== '') { + setShowModelMapping(true); + } + }, [modelValue]); + + // Reset state when dialog opens/closes + useEffect(() => { + if (!open) { + setShowModelMapping(false); + } + }, [open]); + const onSubmit = async (data: FormData) => { try { if (profile) { @@ -62,6 +92,9 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { baseUrl: data.baseUrl, apiKey: data.apiKey, model: data.model, + opusModel: data.opusModel, + sonnetModel: data.sonnetModel, + haikuModel: data.haikuModel, }, }); } else { @@ -78,7 +111,7 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) { return ( - + {profile ? 'Edit Profile' : 'Create API Profile'} @@ -104,8 +137,72 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
- - + + +

+ Leave blank to use: {DEFAULT_MODEL} +

+
+ + {/* Model Mapping Section */} +
+ + + {showModelMapping && ( +
+

+ Configure different model IDs for each tier. Useful for API proxies that route + different model types to different backends. +

+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )}
diff --git a/ui/src/components/profile-editor.tsx b/ui/src/components/profile-editor.tsx new file mode 100644 index 00000000..11190a95 --- /dev/null +++ b/ui/src/components/profile-editor.tsx @@ -0,0 +1,528 @@ +/** + * Profile Editor Component + * Inline editor for API profile settings with tabs for Environment/Raw JSON/Info + */ + +import { useState, useMemo, useCallback, lazy, Suspense } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { MaskedInput } from '@/components/ui/masked-input'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { ConfirmDialog } from '@/components/confirm-dialog'; +import { + Save, + Loader2, + Code2, + Settings, + Info, + Terminal, + Trash2, + RefreshCw, + Plus, + X, +} from 'lucide-react'; +import { toast } from 'sonner'; + +// Lazy load CodeEditor to reduce initial bundle size +const CodeEditor = lazy(() => + import('@/components/code-editor').then((m) => ({ default: m.CodeEditor })) +); + +interface Settings { + env?: Record; +} + +interface SettingsResponse { + profile: string; + settings: Settings; + mtime: number; + path: string; +} + +interface ProfileEditorProps { + profileName: string; + onDelete?: () => void; +} + +export function ProfileEditor({ profileName, onDelete }: ProfileEditorProps) { + const [localEdits, setLocalEdits] = useState>({}); + const [conflictDialog, setConflictDialog] = useState(false); + const [rawJsonEdits, setRawJsonEdits] = useState(null); + const [activeTab, setActiveTab] = useState('env'); + const [newEnvKey, setNewEnvKey] = useState(''); + const queryClient = useQueryClient(); + + // Fetch settings for selected profile + const { data, isLoading, refetch } = useQuery({ + queryKey: ['settings', profileName], + queryFn: () => fetch(`/api/settings/${profileName}/raw`).then((r) => r.json()), + }); + + // Derive raw JSON content + const settings = data?.settings; + const rawJsonContent = useMemo(() => { + if (rawJsonEdits !== null) { + return rawJsonEdits; + } + if (settings) { + return JSON.stringify(settings, null, 2); + } + return ''; + }, [rawJsonEdits, settings]); + + const handleRawJsonChange = useCallback((value: string) => { + setRawJsonEdits(value); + }, []); + + // Derive current settings by merging original data with local edits + const currentSettings = useMemo((): Settings | undefined => { + if (!settings) return undefined; + return { + ...settings, + env: { + ...settings.env, + ...localEdits, + }, + }; + }, [settings, localEdits]); + + // Check if raw JSON is valid + const isRawJsonValid = useMemo(() => { + try { + JSON.parse(rawJsonContent); + return true; + } catch { + return false; + } + }, [rawJsonContent]); + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + if (activeTab === 'raw') { + return rawJsonEdits !== null; + } + return Object.keys(localEdits).length > 0; + }, [activeTab, rawJsonEdits, localEdits]); + + // Save mutation + const saveMutation = useMutation({ + mutationFn: async () => { + let settingsToSave: Settings; + + if (activeTab === 'raw') { + try { + settingsToSave = JSON.parse(rawJsonContent); + } catch { + throw new Error('Invalid JSON'); + } + } else { + settingsToSave = { + ...data?.settings, + env: { + ...data?.settings?.env, + ...localEdits, + }, + }; + } + + const res = await fetch(`/api/settings/${profileName}`, { + 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', profileName] }); + queryClient.invalidateQueries({ queryKey: ['profiles'] }); + setLocalEdits({}); + setRawJsonEdits(null); + toast.success('Settings saved'); + }, + onError: (error: Error) => { + if (error.message === 'CONFLICT') { + setConflictDialog(true); + } else { + toast.error(error.message); + } + }, + }); + + const handleSave = () => { + saveMutation.mutate(); + }; + + const handleConflictResolve = async (overwrite: boolean) => { + setConflictDialog(false); + if (overwrite) { + await refetch(); + saveMutation.mutate(); + } else { + setLocalEdits({}); + setRawJsonEdits(null); + } + }; + + const updateEnvValue = (key: string, value: string) => { + setLocalEdits((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const addNewEnvVar = () => { + if (!newEnvKey.trim()) return; + setLocalEdits((prev) => ({ + ...prev, + [newEnvKey.trim()]: '', + })); + setNewEnvKey(''); + }; + + const isSensitiveKey = (key: string): boolean => { + const sensitivePatterns = [ + /^ANTHROPIC_AUTH_TOKEN$/, + /_API_KEY$/, + /_AUTH_TOKEN$/, + /^API_KEY$/, + /^AUTH_TOKEN$/, + /_SECRET$/, + /^SECRET$/, + ]; + return sensitivePatterns.some((pattern) => pattern.test(key)); + }; + + // Reset state when profile changes + const profileKey = profileName; + + return ( +
+ {/* Header */} +
+
+
+

{profileName}

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

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

+ )} +
+
+ + {onDelete && ( + + )} + +
+
+ + {isLoading ? ( +
+ + Loading settings... +
+ ) : ( + +
+ + + + Environment + + + + Raw JSON + + + + Usage + + + + Info + + +
+ + {/* Environment Tab */} + + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + <> + {Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {isSensitiveKey(key) ? ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + ) : ( + updateEnvValue(key, e.target.value)} + className="font-mono text-sm" + /> + )} +
+ ))} + + {/* Add new env var */} +
+ +
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+ + ) : ( +
+

No environment variables configured.

+

+ Add variables using the Raw JSON tab or the form below. +

+
+ setNewEnvKey(e.target.value.toUpperCase())} + className="font-mono text-sm max-w-xs" + onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()} + /> + +
+
+ )} +
+
+
+ + {/* Raw JSON Tab */} + + + + Loading editor... +
+ } + > +
+ {!isRawJsonValid && rawJsonEdits !== null && ( +
+ + Invalid JSON syntax +
+ )} +
+ +
+
+ + + + {/* Usage Tab */} + + +
+ + + CLI Usage + Use this profile from the command line + + +
+ + + ccs {profileName} "your prompt here" + +
+
+ + + ccs {profileName} + +
+
+ + + ccs default {profileName} + +
+
+
+ + + + Environment Variables + Variables set when using this profile + + +
+ {currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? ( + Object.entries(currentSettings.env).map(([key, value]) => ( +
+ + {key} + + + {isSensitiveKey(key) ? '••••••••' : value} + +
+ )) + ) : ( +

+ No environment variables set +

+ )} +
+
+
+
+
+
+ + {/* Info Tab */} + + +
+ + + Profile Information + Details about this configuration file + + + {data && ( + <> +
+ Profile Name + {data.profile} +
+
+ File Path + + {data.path} + +
+
+ Last Modified + {new Date(data.mtime).toLocaleString()} +
+
+ Variables + {Object.keys(currentSettings?.env || {}).length} configured +
+ + )} +
+
+
+
+
+ + )} + + handleConflictResolve(true)} + onCancel={() => handleConflictResolve(false)} + /> +
+ ); +} diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index 4a8932a0..40a6a0c7 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -31,12 +31,18 @@ export interface CreateProfile { baseUrl: string; apiKey: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface UpdateProfile { baseUrl?: string; apiKey?: string; model?: string; + opusModel?: string; + sonnetModel?: string; + haikuModel?: string; } export interface Variant { diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 74c0e72b..d0229df3 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -1,66 +1,331 @@ /** - * API Profiles Page - * Phase 03: REST API Routes & CRUD + * API Profiles Page - Master-Detail Layout + * Comprehensive profile management with inline editing */ -import { useState } from 'react'; +import { useState, useMemo } from 'react'; import { Button } from '@/components/ui/button'; -import { Plus } from 'lucide-react'; -import { ProfilesTable } from '@/components/profiles-table'; -import { ProfileDialog } from '@/components/profile-dialog'; -import { SettingsDialog } from '@/components/settings-dialog'; -import { useProfiles } from '@/hooks/use-profiles'; +import { Input } from '@/components/ui/input'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Badge } from '@/components/ui/badge'; +import { Separator } from '@/components/ui/separator'; +import { + Plus, + Search, + Settings2, + Trash2, + CheckCircle2, + AlertCircle, + Server, + ExternalLink, + FileJson, +} from 'lucide-react'; +import { ProfileEditor } from '@/components/profile-editor'; +import { ProfileCreateForm } from '@/components/profile-create-form'; +import { useProfiles, useDeleteProfile } from '@/hooks/use-profiles'; +import { ConfirmDialog } from '@/components/confirm-dialog'; import type { Profile } from '@/lib/api-client'; +import { cn } from '@/lib/utils'; export function ApiPage() { - const [dialogOpen, setDialogOpen] = useState(false); - const [editingProfile, setEditingProfile] = useState(null); - const [settingsDialogOpen, setSettingsDialogOpen] = useState(false); - const [settingsProfileName, setSettingsProfileName] = useState(null); const { data, isLoading } = useProfiles(); + const deleteMutation = useDeleteProfile(); + const [selectedProfile, setSelectedProfile] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [showCreateForm, setShowCreateForm] = useState(false); + const [deleteConfirm, setDeleteConfirm] = useState(null); - const handleEditSettings = (profile: Profile) => { - setSettingsProfileName(profile.name); - setSettingsDialogOpen(true); + // Memoize profiles to maintain stable reference + const profiles = useMemo(() => data?.profiles || [], [data?.profiles]); + + // Filter profiles by search + const filteredProfiles = useMemo( + () => profiles.filter((p) => p.name.toLowerCase().includes(searchQuery.toLowerCase())), + [profiles, searchQuery] + ); + + // Compute effective selected profile (auto-select first if none selected) + const effectiveSelectedProfile = useMemo(() => { + if (showCreateForm) return null; + if (selectedProfile && profiles.some((p) => p.name === selectedProfile)) { + return selectedProfile; + } + return profiles.length > 0 ? profiles[0].name : null; + }, [selectedProfile, profiles, showCreateForm]); + + // Handle profile deletion + const handleDelete = (name: string) => { + deleteMutation.mutate(name, { + onSuccess: () => { + if (selectedProfile === name) { + setSelectedProfile(null); + } + setDeleteConfirm(null); + }, + }); }; - const handleCloseDialog = () => { - setDialogOpen(false); - setEditingProfile(null); + // Handle create success + const handleCreateSuccess = (name: string) => { + setShowCreateForm(false); + setSelectedProfile(name); }; - const handleCloseSettingsDialog = () => { - setSettingsDialogOpen(false); - setSettingsProfileName(null); - }; + const selectedProfileData = profiles.find((p) => p.name === effectiveSelectedProfile); return ( -
-
-
-

API Profiles

-

- Manage custom API profiles for Claude CLI -

+
+ {/* Left Panel - Profiles List */} +
+ {/* Header */} +
+
+
+ +

API Profiles

+
+ +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + /> +
- + + {/* Profile List */} + + {isLoading ? ( +
Loading profiles...
+ ) : filteredProfiles.length === 0 ? ( +
+ {profiles.length === 0 ? ( +
+ +
+

No API profiles yet

+

+ Create your first profile to connect to custom API endpoints +

+
+ +
+ ) : ( +

+ No profiles match "{searchQuery}" +

+ )} +
+ ) : ( +
+ {filteredProfiles.map((profile) => ( + { + setSelectedProfile(profile.name); + setShowCreateForm(false); + }} + onDelete={() => setDeleteConfirm(profile.name)} + /> + ))} +
+ )} +
+ + {/* Footer Stats */} + {profiles.length > 0 && ( +
+
+ + {profiles.length} profile{profiles.length !== 1 ? 's' : ''} + + + + {profiles.filter((p) => p.configured).length} configured + +
+
+ )}
- {isLoading ? ( -
Loading profiles...
- ) : ( - - )} + {/* Right Panel - Editor */} +
+ {showCreateForm ? ( + { + setShowCreateForm(false); + if (profiles.length > 0) { + setSelectedProfile(profiles[0].name); + } + }} + /> + ) : selectedProfileData ? ( + setDeleteConfirm(selectedProfileData.name)} + /> + ) : ( + { + setShowCreateForm(true); + setSelectedProfile(null); + }} + /> + )} +
- - deleteConfirm && handleDelete(deleteConfirm)} + onCancel={() => setDeleteConfirm(null)} />
); } + +/** Profile list item component */ +function ProfileListItem({ + profile, + isSelected, + onSelect, + onDelete, +}: { + profile: Profile; + isSelected: boolean; + onSelect: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Status indicator */} + {profile.configured ? ( + + ) : ( + + )} + + {/* Profile info */} +
+
{profile.name}
+
{profile.settingsPath}
+
+ + {/* Actions */} + +
+ ); +} + +/** Empty state when no profile is selected */ +function EmptyState({ onCreateClick }: { onCreateClick: () => void }) { + return ( +
+
+ +

API Profile Manager

+

+ Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api, + OpenRouter, or your own API backend. +

+ +
+ + + + +
+

+ What you can configure: +

+
    +
  • + + URL + + Custom API base URL endpoint +
  • +
  • + + Auth + + API key or authentication token +
  • +
  • + + Models + + Model mapping for Opus/Sonnet/Haiku +
  • +
+
+ + +
+
+
+ ); +}