feat(api-profile-ux): Implement API & UI for profile management

This commit is contained in:
kaitranntt
2025-12-10 21:36:11 -05:00
parent 2b1a3b4879
commit 83570050ef
7 changed files with 1359 additions and 66 deletions
+34 -13
View File
@@ -370,32 +370,53 @@ async function handleCreate(args: string[]): Promise<void> {
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;
}
}
+64 -7
View File
@@ -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 });
+319
View File
@@ -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<typeof schema>;
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<string | null>(null);
const {
register,
handleSubmit,
formState: { errors },
watch,
} = useForm<FormData>({
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 (
<div className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b bg-background flex items-center gap-4">
<Button variant="ghost" size="sm" onClick={onCancel}>
<ArrowLeft className="w-4 h-4" />
</Button>
<div>
<h2 className="text-lg font-semibold">Create API Profile</h2>
<p className="text-xs text-muted-foreground">Configure a new custom API endpoint</p>
</div>
</div>
<ScrollArea className="flex-1">
<form onSubmit={handleSubmit(onSubmit)} className="p-6 max-w-2xl">
<div className="space-y-6">
{/* Basic Info Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Basic Information</CardTitle>
<CardDescription>Profile name and API connection details</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Name */}
<div className="space-y-1.5">
<Label htmlFor="name">Profile Name</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used as: <code className="bg-muted px-1 rounded">ccs my-api "prompt"</code>
</p>
)}
</div>
{/* Base URL */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">API Base URL</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : (
<p className="text-xs text-muted-foreground">
Base URL without /chat/completions (Claude adds this)
</p>
)}
</div>
{/* API Key */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">API Key</Label>
<Input
id="apiKey"
type="password"
{...register('apiKey')}
placeholder="••••••••••••••••"
/>
{errors.apiKey && (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
)}
</div>
</CardContent>
</Card>
{/* Model Configuration Card */}
<Card>
<CardHeader>
<CardTitle className="text-base">Model Configuration</CardTitle>
<CardDescription>Configure which models to use with this API</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Default Model */}
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_MODEL
</Badge>
</Label>
<Input
id="model"
{...register('model')}
placeholder={DEFAULT_MODEL}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
Leave blank to use: {DEFAULT_MODEL}
</p>
</div>
{/* Model Mapping Expander */}
<div className="border rounded-md">
<button
type="button"
className="w-full flex items-center justify-between p-3 text-sm hover:bg-muted/50 transition-colors"
onClick={() => setShowModelMapping(!showModelMapping)}
>
<div className="flex items-center gap-2">
<span className="font-medium">Model Mapping</span>
<Badge variant="secondary" className="text-[10px]">
Opus / Sonnet / Haiku
</Badge>
</div>
{showModelMapping ? (
<ChevronDown className="w-4 h-4 text-muted-foreground" />
) : (
<ChevronRight className="w-4 h-4 text-muted-foreground" />
)}
</button>
{showModelMapping && (
<div className="p-4 pt-0 space-y-4 border-t bg-muted/30">
<div className="flex items-start gap-2 text-xs text-muted-foreground bg-background p-2 rounded">
<HelpCircle className="w-4 h-4 shrink-0 mt-0.5" />
<span>
Configure different model IDs for each tier. Useful for API proxies that
route Opus/Sonnet/Haiku to different backends.
</span>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_DEFAULT_OPUS_MODEL
</Badge>
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_DEFAULT_SONNET_MODEL
</Badge>
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Model
<Badge variant="outline" className="ml-2 text-[10px]">
ANTHROPIC_DEFAULT_HAIKU_MODEL
</Badge>
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="font-mono text-sm h-9"
/>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3 pt-4">
<Button type="button" variant="outline" onClick={onCancel}>
Cancel
</Button>
<Button type="submit" disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</>
)}
</Button>
</div>
</div>
</form>
</ScrollArea>
</div>
);
}
+100 -3
View File
@@ -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<typeof schema>;
@@ -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<FormData>({
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 (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{profile ? 'Edit Profile' : 'Create API Profile'}</DialogTitle>
</DialogHeader>
@@ -104,8 +137,72 @@ export function ProfileDialog({ open, onClose, profile }: ProfileDialogProps) {
</div>
<div>
<Label htmlFor="model">Model (optional)</Label>
<Input id="model" {...register('model')} placeholder="claude-sonnet-4-5-20250929" />
<Label htmlFor="model">Default Model (ANTHROPIC_MODEL)</Label>
<Input id="model" {...register('model')} placeholder={DEFAULT_MODEL} />
<p className="text-xs text-muted-foreground mt-1">
Leave blank to use: {DEFAULT_MODEL}
</p>
</div>
{/* Model Mapping Section */}
<div className="border rounded-md">
<button
type="button"
className="w-full flex items-center justify-between p-3 text-sm font-medium hover:bg-muted/50 transition-colors"
onClick={() => setShowModelMapping(!showModelMapping)}
>
<span>Model Mapping (Opus/Sonnet/Haiku)</span>
{showModelMapping ? (
<ChevronDown className="w-4 h-4" />
) : (
<ChevronRight className="w-4 h-4" />
)}
</button>
{showModelMapping && (
<div className="p-3 pt-0 space-y-3 border-t">
<p className="text-xs text-muted-foreground">
Configure different model IDs for each tier. Useful for API proxies that route
different model types to different backends.
</p>
<div>
<Label htmlFor="opusModel" className="text-xs">
Opus Model (ANTHROPIC_DEFAULT_OPUS_MODEL)
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="h-8 text-sm"
/>
</div>
<div>
<Label htmlFor="sonnetModel" className="text-xs">
Sonnet Model (ANTHROPIC_DEFAULT_SONNET_MODEL)
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="h-8 text-sm"
/>
</div>
<div>
<Label htmlFor="haikuModel" className="text-xs">
Haiku Model (ANTHROPIC_DEFAULT_HAIKU_MODEL)
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder={modelValue || DEFAULT_MODEL}
className="h-8 text-sm"
/>
</div>
</div>
)}
</div>
<div className="flex justify-end gap-2 pt-2">
+528
View File
@@ -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<string, string>;
}
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<Record<string, string>>({});
const [conflictDialog, setConflictDialog] = useState(false);
const [rawJsonEdits, setRawJsonEdits] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('env');
const [newEnvKey, setNewEnvKey] = useState('');
const queryClient = useQueryClient();
// Fetch settings for selected profile
const { data, isLoading, refetch } = useQuery<SettingsResponse>({
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 (
<div key={profileKey} className="flex-1 flex flex-col overflow-hidden">
{/* Header */}
<div className="px-6 py-4 border-b bg-background flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">{profileName}</h2>
{data && (
<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 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>
{onDelete && (
<Button variant="ghost" size="sm" onClick={onDelete}>
<Trash2 className="w-4 h-4 text-destructive" />
</Button>
)}
<Button
size="sm"
onClick={handleSave}
disabled={
saveMutation.isPending || !hasChanges || (activeTab === 'raw' && !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>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex-1 flex flex-col overflow-hidden"
>
<div className="px-6 pt-4">
<TabsList className="w-full justify-start bg-muted/50">
<TabsTrigger value="env" className="gap-1.5">
<Settings className="w-4 h-4" />
Environment
</TabsTrigger>
<TabsTrigger value="raw" className="gap-1.5">
<Code2 className="w-4 h-4" />
Raw JSON
</TabsTrigger>
<TabsTrigger value="usage" className="gap-1.5">
<Terminal className="w-4 h-4" />
Usage
</TabsTrigger>
<TabsTrigger value="info" className="gap-1.5">
<Info className="w-4 h-4" />
Info
</TabsTrigger>
</TabsList>
</div>
{/* Environment Tab */}
<TabsContent value="env" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
<ScrollArea className="h-full">
<div className="space-y-6 py-4">
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
<>
{Object.entries(currentSettings.env).map(([key, value]) => (
<div key={key} className="space-y-1.5">
<Label className="text-sm font-medium flex items-center gap-2">
{key}
{isSensitiveKey(key) && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
sensitive
</Badge>
)}
</Label>
{isSensitiveKey(key) ? (
<MaskedInput
value={value}
onChange={(e) => updateEnvValue(key, e.target.value)}
className="font-mono text-sm"
/>
) : (
<Input
value={value}
onChange={(e) => updateEnvValue(key, e.target.value)}
className="font-mono text-sm"
/>
)}
</div>
))}
{/* Add new env var */}
<div className="pt-4 border-t">
<Label className="text-sm font-medium text-muted-foreground">
Add Environment Variable
</Label>
<div className="flex gap-2 mt-2">
<Input
placeholder="VARIABLE_NAME"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
className="font-mono text-sm"
onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()}
/>
<Button
variant="outline"
size="sm"
onClick={addNewEnvVar}
disabled={!newEnvKey.trim()}
>
<Plus className="w-4 h-4" />
</Button>
</div>
</div>
</>
) : (
<div className="py-12 text-center text-muted-foreground bg-muted/30 rounded-lg border border-dashed">
<p>No environment variables configured.</p>
<p className="text-xs mt-1">
Add variables using the Raw JSON tab or the form below.
</p>
<div className="flex gap-2 mt-4 justify-center">
<Input
placeholder="VARIABLE_NAME"
value={newEnvKey}
onChange={(e) => setNewEnvKey(e.target.value.toUpperCase())}
className="font-mono text-sm max-w-xs"
onKeyDown={(e) => e.key === 'Enter' && addNewEnvVar()}
/>
<Button
variant="outline"
size="sm"
onClick={addNewEnvVar}
disabled={!newEnvKey.trim()}
>
<Plus className="w-4 h-4 mr-1" />
Add
</Button>
</div>
</div>
)}
</div>
</ScrollArea>
</TabsContent>
{/* Raw JSON Tab */}
<TabsContent value="raw" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
<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">
<X className="w-4 h-4" />
Invalid JSON syntax
</div>
)}
<div className="flex-1 border rounded-md overflow-hidden">
<CodeEditor
value={rawJsonContent}
onChange={handleRawJsonChange}
language="json"
minHeight="100%"
/>
</div>
</div>
</Suspense>
</TabsContent>
{/* Usage Tab */}
<TabsContent value="usage" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
<ScrollArea className="h-full">
<div className="py-4 space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-base">CLI Usage</CardTitle>
<CardDescription>Use this profile from the command line</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-xs text-muted-foreground">
Run Claude with this profile
</Label>
<code className="block mt-1 p-3 bg-muted rounded-md text-sm font-mono">
ccs {profileName} "your prompt here"
</code>
</div>
<div>
<Label className="text-xs text-muted-foreground">
Start interactive session
</Label>
<code className="block mt-1 p-3 bg-muted rounded-md text-sm font-mono">
ccs {profileName}
</code>
</div>
<div>
<Label className="text-xs text-muted-foreground">Set as default</Label>
<code className="block mt-1 p-3 bg-muted rounded-md text-sm font-mono">
ccs default {profileName}
</code>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Environment Variables</CardTitle>
<CardDescription>Variables set when using this profile</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{currentSettings?.env && Object.keys(currentSettings.env).length > 0 ? (
Object.entries(currentSettings.env).map(([key, value]) => (
<div key={key} className="flex items-center gap-2 text-sm">
<Badge variant="outline" className="font-mono text-xs">
{key}
</Badge>
<span className="text-muted-foreground font-mono truncate">
{isSensitiveKey(key) ? '••••••••' : value}
</span>
</div>
))
) : (
<p className="text-sm text-muted-foreground">
No environment variables set
</p>
)}
</div>
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
{/* Info Tab */}
<TabsContent value="info" className="flex-1 overflow-hidden px-6 pb-6 mt-0">
<ScrollArea className="h-full">
<div className="py-4">
<Card>
<CardHeader>
<CardTitle className="text-base">Profile Information</CardTitle>
<CardDescription>Details about this configuration file</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{data && (
<>
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
<span className="font-medium text-muted-foreground">Profile Name</span>
<span className="font-mono">{data.profile}</span>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
<span className="font-medium text-muted-foreground">File Path</span>
<code className="bg-muted p-1 rounded text-xs break-all">
{data.path}
</code>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
<span className="font-medium text-muted-foreground">Last Modified</span>
<span>{new Date(data.mtime).toLocaleString()}</span>
</div>
<div className="grid grid-cols-[120px_1fr] gap-2 text-sm">
<span className="font-medium text-muted-foreground">Variables</span>
<span>{Object.keys(currentSettings?.env || {}).length} configured</span>
</div>
</>
)}
</CardContent>
</Card>
</div>
</ScrollArea>
</TabsContent>
</Tabs>
)}
<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)}
/>
</div>
);
}
+6
View File
@@ -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 {
+308 -43
View File
@@ -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<Profile | null>(null);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [settingsProfileName, setSettingsProfileName] = useState<string | null>(null);
const { data, isLoading } = useProfiles();
const deleteMutation = useDeleteProfile();
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">API Profiles</h1>
<p className="text-sm text-muted-foreground mt-1">
Manage custom API profiles for Claude CLI
</p>
<div className="h-[calc(100vh-60px)] flex">
{/* Left Panel - Profiles List */}
<div className="w-80 border-r flex flex-col bg-muted/30">
{/* Header */}
<div className="p-4 border-b bg-background">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-primary" />
<h1 className="font-semibold">API Profiles</h1>
</div>
<Button
size="sm"
onClick={() => {
setShowCreateForm(true);
setSelectedProfile(null);
}}
>
<Plus className="w-4 h-4 mr-1" />
New
</Button>
</div>
{/* Search */}
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search profiles..."
className="pl-8 h-9"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<Button onClick={() => setDialogOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</Button>
{/* Profile List */}
<ScrollArea className="flex-1">
{isLoading ? (
<div className="p-4 text-sm text-muted-foreground">Loading profiles...</div>
) : filteredProfiles.length === 0 ? (
<div className="p-4 text-center">
{profiles.length === 0 ? (
<div className="space-y-3 py-8">
<FileJson className="w-12 h-12 mx-auto text-muted-foreground/50" />
<div>
<p className="text-sm font-medium">No API profiles yet</p>
<p className="text-xs text-muted-foreground mt-1">
Create your first profile to connect to custom API endpoints
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCreateForm(true);
setSelectedProfile(null);
}}
>
<Plus className="w-4 h-4 mr-1" />
Create Profile
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground py-4">
No profiles match "{searchQuery}"
</p>
)}
</div>
) : (
<div className="p-2 space-y-1">
{filteredProfiles.map((profile) => (
<ProfileListItem
key={profile.name}
profile={profile}
isSelected={effectiveSelectedProfile === profile.name}
onSelect={() => {
setSelectedProfile(profile.name);
setShowCreateForm(false);
}}
onDelete={() => setDeleteConfirm(profile.name)}
/>
))}
</div>
)}
</ScrollArea>
{/* Footer Stats */}
{profiles.length > 0 && (
<div className="p-3 border-t bg-background text-xs text-muted-foreground">
<div className="flex items-center justify-between">
<span>
{profiles.length} profile{profiles.length !== 1 ? 's' : ''}
</span>
<span className="flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-600" />
{profiles.filter((p) => p.configured).length} configured
</span>
</div>
</div>
)}
</div>
{isLoading ? (
<div className="text-muted-foreground">Loading profiles...</div>
) : (
<ProfilesTable data={data?.profiles || []} onEditSettings={handleEditSettings} />
)}
{/* Right Panel - Editor */}
<div className="flex-1 flex flex-col min-w-0">
{showCreateForm ? (
<ProfileCreateForm
onSuccess={handleCreateSuccess}
onCancel={() => {
setShowCreateForm(false);
if (profiles.length > 0) {
setSelectedProfile(profiles[0].name);
}
}}
/>
) : selectedProfileData ? (
<ProfileEditor
profileName={selectedProfileData.name}
onDelete={() => setDeleteConfirm(selectedProfileData.name)}
/>
) : (
<EmptyState
onCreateClick={() => {
setShowCreateForm(true);
setSelectedProfile(null);
}}
/>
)}
</div>
<ProfileDialog open={dialogOpen} onClose={handleCloseDialog} profile={editingProfile} />
<SettingsDialog
open={settingsDialogOpen}
onClose={handleCloseSettingsDialog}
profileName={settingsProfileName}
{/* Delete Confirmation */}
<ConfirmDialog
open={!!deleteConfirm}
title="Delete Profile"
description={`Are you sure you want to delete "${deleteConfirm}"? This will remove the settings file and cannot be undone.`}
confirmText="Delete"
variant="destructive"
onConfirm={() => deleteConfirm && handleDelete(deleteConfirm)}
onCancel={() => setDeleteConfirm(null)}
/>
</div>
);
}
/** Profile list item component */
function ProfileListItem({
profile,
isSelected,
onSelect,
onDelete,
}: {
profile: Profile;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}) {
return (
<div
className={cn(
'group flex items-center gap-2 px-3 py-2.5 rounded-md cursor-pointer transition-colors',
isSelected
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-muted border border-transparent'
)}
onClick={onSelect}
>
{/* Status indicator */}
{profile.configured ? (
<CheckCircle2 className="w-4 h-4 text-green-600 shrink-0" />
) : (
<AlertCircle className="w-4 h-4 text-yellow-600 shrink-0" />
)}
{/* Profile info */}
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{profile.name}</div>
<div className="text-xs text-muted-foreground truncate">{profile.settingsPath}</div>
</div>
{/* Actions */}
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="w-3.5 h-3.5 text-destructive" />
</Button>
</div>
);
}
/** Empty state when no profile is selected */
function EmptyState({ onCreateClick }: { onCreateClick: () => void }) {
return (
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<Settings2 className="w-16 h-16 mx-auto text-muted-foreground/30 mb-6" />
<h2 className="text-xl font-semibold mb-2">API Profile Manager</h2>
<p className="text-muted-foreground mb-6">
Configure custom API endpoints for Claude CLI. Connect to proxy services like copilot-api,
OpenRouter, or your own API backend.
</p>
<div className="space-y-3">
<Button onClick={onCreateClick} className="w-full">
<Plus className="w-4 h-4 mr-2" />
Create Your First Profile
</Button>
<Separator className="my-4" />
<div className="text-left space-y-2">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
What you can configure:
</p>
<ul className="text-sm text-muted-foreground space-y-1.5">
<li className="flex items-start gap-2">
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
URL
</Badge>
<span>Custom API base URL endpoint</span>
</li>
<li className="flex items-start gap-2">
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
Auth
</Badge>
<span>API key or authentication token</span>
</li>
<li className="flex items-start gap-2">
<Badge variant="outline" className="text-xs shrink-0 mt-0.5">
Models
</Badge>
<span>Model mapping for Opus/Sonnet/Haiku</span>
</li>
</ul>
</div>
<div className="pt-4">
<a
href="https://github.com/kaitranntt/ccs#api-profiles"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center text-xs text-primary hover:underline"
>
Learn more about API profiles
<ExternalLink className="w-3 h-3 ml-1" />
</a>
</div>
</div>
</div>
</div>
);
}