mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-07 16:41:06 +08:00
feat(frontend): implement comprehensive API Settings Management Interface
Implement a complete API Management Interface for React frontend with split- panel layout, migrating all features from legacy JS frontend. New Features: - API Settings page with 5 tabs: Providers, Endpoints, Cache, Model Pools, CLI Settings - Provider Management: CRUD operations, multi-key rotation, health checks, test connection - Endpoint Management: CRUD operations, cache strategy configuration, enable/disable toggle - Cache Settings: Global configuration, statistics display, clear cache functionality - Model Pool Management: CRUD operations, auto-discovery feature, provider exclusion - CLI Settings Management: Provider-based and Direct modes, full CRUD support - Multi-Key Settings Modal: Manage API keys with rotation strategies and weights - Manage Models Modal: View and manage models per provider (LLM and Embedding) - Sync to CodexLens: Integration handler for provider configuration sync Technical Implementation: - Created 12 new React components in components/api-settings/ - Extended lib/api.ts with 460+ lines of API client functions - Created hooks/useApiSettings.ts with 772 lines of TanStack Query hooks - Added RadioGroup UI component for form selections - Implemented unified error handling with useNotifications across all operations - Complete i18n support (500+ keys in English and Chinese) - Route integration (/api-settings) and sidebar navigation Code Quality: - All acceptance criteria from plan.json verified - Code review passed with Gemini (all 7 IMPL tasks complete) - Follows existing patterns: Shadcn UI, TanStack Query, react-intl, Lucide icons
This commit is contained in:
408
ccw/frontend/src/components/api-settings/CliSettingsModal.tsx
Normal file
408
ccw/frontend/src/components/api-settings/CliSettingsModal.tsx
Normal file
@@ -0,0 +1,408 @@
|
||||
// ========================================
|
||||
// CLI Settings Modal Component
|
||||
// ========================================
|
||||
// Add/Edit CLI settings modal with provider-based and direct modes
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Check, Eye, EyeOff } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
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 { Textarea } from '@/components/ui/Textarea';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/Tabs';
|
||||
import { useCreateCliSettings, useUpdateCliSettings, useProviders } from '@/hooks/useApiSettings';
|
||||
import { useNotifications } from '@/hooks/useNotifications';
|
||||
import type { CliSettingsEndpoint } from '@/lib/api';
|
||||
|
||||
// ========== Types ==========
|
||||
|
||||
export interface CliSettingsModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
cliSettings?: CliSettingsEndpoint | null;
|
||||
}
|
||||
|
||||
type ModeType = 'provider-based' | 'direct';
|
||||
|
||||
// ========== Main Component ==========
|
||||
|
||||
export function CliSettingsModal({ open, onClose, cliSettings }: CliSettingsModalProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const { showNotification } = useNotifications();
|
||||
const isEditing = !!cliSettings;
|
||||
|
||||
// Mutations
|
||||
const { createCliSettings, isCreating } = useCreateCliSettings();
|
||||
const { updateCliSettings, isUpdating } = useUpdateCliSettings();
|
||||
|
||||
// Get providers for provider-based mode
|
||||
const { providers } = useProviders();
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [mode, setMode] = useState<ModeType>('direct');
|
||||
|
||||
// Provider-based mode state
|
||||
const [providerId, setProviderId] = useState('');
|
||||
const [model, setModel] = useState('sonnet');
|
||||
const [includeCoAuthoredBy, setIncludeCoAuthoredBy] = useState(false);
|
||||
|
||||
// Direct mode state
|
||||
const [authToken, setAuthToken] = useState('');
|
||||
const [baseUrl, setBaseUrl] = useState('');
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
|
||||
// Validation errors
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Initialize form from cliSettings
|
||||
useEffect(() => {
|
||||
if (cliSettings) {
|
||||
setName(cliSettings.name);
|
||||
setDescription(cliSettings.description || '');
|
||||
setEnabled(cliSettings.enabled);
|
||||
setModel(cliSettings.settings.model || 'sonnet');
|
||||
setIncludeCoAuthoredBy(cliSettings.settings.includeCoAuthoredBy || false);
|
||||
|
||||
// Determine mode based on settings
|
||||
const hasCustomBaseUrl = Boolean(
|
||||
cliSettings.settings.env.ANTHROPIC_BASE_URL &&
|
||||
!cliSettings.settings.env.ANTHROPIC_BASE_URL.includes('api.anthropic.com')
|
||||
);
|
||||
|
||||
if (hasCustomBaseUrl) {
|
||||
setMode('direct');
|
||||
setBaseUrl(cliSettings.settings.env.ANTHROPIC_BASE_URL || '');
|
||||
setAuthToken(cliSettings.settings.env.ANTHROPIC_AUTH_TOKEN || '');
|
||||
} else {
|
||||
setMode('provider-based');
|
||||
// Try to find matching provider
|
||||
const matchingProvider = providers.find((p) => p.apiBase === cliSettings.settings.env.ANTHROPIC_BASE_URL);
|
||||
if (matchingProvider) {
|
||||
setProviderId(matchingProvider.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset form for new CLI settings
|
||||
setName('');
|
||||
setDescription('');
|
||||
setEnabled(true);
|
||||
setMode('direct');
|
||||
setProviderId('');
|
||||
setModel('sonnet');
|
||||
setIncludeCoAuthoredBy(false);
|
||||
setAuthToken('');
|
||||
setBaseUrl('');
|
||||
setErrors({});
|
||||
}
|
||||
}, [cliSettings, open, providers]);
|
||||
|
||||
// Validate form
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!name.trim()) {
|
||||
newErrors.name = formatMessage({ id: 'apiSettings.validation.nameRequired' });
|
||||
}
|
||||
|
||||
if (mode === 'provider-based') {
|
||||
if (!providerId) {
|
||||
newErrors.providerId = formatMessage({ id: 'apiSettings.cliSettings.validation.providerRequired' });
|
||||
}
|
||||
} else {
|
||||
// Direct mode
|
||||
if (!authToken.trim() && !baseUrl.trim()) {
|
||||
newErrors.direct = formatMessage({ id: 'apiSettings.cliSettings.validation.authOrBaseUrlRequired' });
|
||||
}
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
// Handle save
|
||||
const handleSave = async () => {
|
||||
if (!validateForm()) return;
|
||||
|
||||
try {
|
||||
// Build settings object based on mode
|
||||
const env: Record<string, string> = {
|
||||
DISABLE_AUTOUPDATER: '1',
|
||||
};
|
||||
|
||||
if (mode === 'provider-based') {
|
||||
// Provider-based mode: get settings from selected provider
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
if (provider) {
|
||||
if (provider.apiBase) {
|
||||
env.ANTHROPIC_BASE_URL = provider.apiBase;
|
||||
}
|
||||
if (provider.apiKey) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = provider.apiKey;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Direct mode: use manual input
|
||||
if (authToken.trim()) {
|
||||
env.ANTHROPIC_AUTH_TOKEN = authToken.trim();
|
||||
}
|
||||
if (baseUrl.trim()) {
|
||||
env.ANTHROPIC_BASE_URL = baseUrl.trim();
|
||||
}
|
||||
}
|
||||
|
||||
const request = {
|
||||
id: cliSettings?.id,
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
enabled,
|
||||
settings: {
|
||||
env,
|
||||
model,
|
||||
includeCoAuthoredBy,
|
||||
},
|
||||
};
|
||||
|
||||
if (isEditing && cliSettings) {
|
||||
await updateCliSettings(cliSettings.id, request);
|
||||
} else {
|
||||
await createCliSettings(request);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (error) {
|
||||
showNotification('error', formatMessage({ id: 'apiSettings.cliSettings.saveError' }));
|
||||
}
|
||||
};
|
||||
|
||||
// Get selected provider info
|
||||
const selectedProvider = providers.find((p) => p.id === providerId);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{isEditing
|
||||
? formatMessage({ id: 'apiSettings.cliSettings.actions.edit' })
|
||||
: formatMessage({ id: 'apiSettings.cliSettings.actions.add' })}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.modalDescription' })}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* Common Fields */}
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">
|
||||
{formatMessage({ id: 'apiSettings.common.name' })}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.namePlaceholder' })}
|
||||
className={errors.name ? 'border-destructive' : ''}
|
||||
/>
|
||||
{errors.name && <p className="text-sm text-destructive">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">
|
||||
{formatMessage({ id: 'apiSettings.common.description' })}
|
||||
</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={formatMessage({ id: 'apiSettings.cliSettings.descriptionPlaceholder' })}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="enabled"
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<Label htmlFor="enabled" className="cursor-pointer">
|
||||
{formatMessage({ id: 'apiSettings.common.enableThis' })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mode Tabs */}
|
||||
<Tabs value={mode} onValueChange={(v) => setMode(v as ModeType)} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="provider-based">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.providerBased' })}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="direct">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.direct' })}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Provider-based Mode */}
|
||||
<TabsContent value="provider-based" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="providerId">
|
||||
{formatMessage({ id: 'apiSettings.common.provider' })}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Select value={providerId} onValueChange={setProviderId}>
|
||||
<SelectTrigger className={errors.providerId ? 'border-destructive' : ''}>
|
||||
<SelectValue placeholder={formatMessage({ id: 'apiSettings.cliSettings.selectProvider' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((provider) => (
|
||||
<SelectItem key={provider.id} value={provider.id}>
|
||||
{provider.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.providerId && <p className="text-sm text-destructive">{errors.providerId}</p>}
|
||||
</div>
|
||||
|
||||
{selectedProvider && (
|
||||
<div className="p-4 bg-muted/50 rounded-lg space-y-2">
|
||||
<p className="text-sm font-medium">{selectedProvider.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatMessage({ id: 'apiSettings.common.type' })}: {selectedProvider.type}
|
||||
</p>
|
||||
{selectedProvider.apiBase && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{formatMessage({ id: 'apiSettings.providers.apiBaseUrl' })}: {selectedProvider.apiBase}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-pb">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.model' })}
|
||||
</Label>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id="model-pb">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="opus">Opus</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet</SelectItem>
|
||||
<SelectItem value="haiku">Haiku</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Direct Mode */}
|
||||
<TabsContent value="direct" className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="authToken">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.authToken' })}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="authToken"
|
||||
type={showToken ? 'text' : 'password'}
|
||||
value={authToken}
|
||||
onChange={(e) => setAuthToken(e.target.value)}
|
||||
placeholder="sk-ant-..."
|
||||
className={errors.direct ? 'border-destructive pr-10' : 'pr-10'}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full px-2"
|
||||
onClick={() => setShowToken(!showToken)}
|
||||
>
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="baseUrl">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.baseUrl' })}
|
||||
</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.anthropic.com"
|
||||
className={errors.direct ? 'border-destructive' : ''}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{errors.direct && <p className="text-sm text-destructive">{errors.direct}</p>}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model-direct">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.model' })}
|
||||
</Label>
|
||||
<Select value={model} onValueChange={setModel}>
|
||||
<SelectTrigger id="model-direct">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="opus">Opus</SelectItem>
|
||||
<SelectItem value="sonnet">Sonnet</SelectItem>
|
||||
<SelectItem value="haiku">Haiku</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Additional Settings (both modes) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="coAuthored"
|
||||
checked={includeCoAuthoredBy}
|
||||
onCheckedChange={setIncludeCoAuthoredBy}
|
||||
/>
|
||||
<Label htmlFor="coAuthored" className="cursor-pointer">
|
||||
{formatMessage({ id: 'apiSettings.cliSettings.includeCoAuthoredBy' })}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{formatMessage({ id: 'common.actions.cancel' })}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isCreating || isUpdating}>
|
||||
{(isCreating || isUpdating) ? (
|
||||
formatMessage({ id: 'common.actions.saving' })
|
||||
) : (
|
||||
<>
|
||||
<Check className="w-4 h-4 mr-2" />
|
||||
{formatMessage({ id: 'common.actions.save' })}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default CliSettingsModal;
|
||||
Reference in New Issue
Block a user