// ======================================== // CLI Settings Modal Component // ======================================== // Add/Edit CLI settings modal with multi-provider support (Claude/Codex/Gemini) import { useState, useEffect } from 'react'; import { useIntl } from 'react-intl'; import { Check, Eye, EyeOff, X, Plus, Loader2, Download } 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 { fetchCodexConfigPreview, fetchGeminiConfigPreview } from '@/lib/api'; import type { CliSettingsEndpoint, CliProvider } from '@/lib/api'; // ========== Types ========== export interface CliSettingsModalProps { open: boolean; onClose: () => void; cliSettings?: CliSettingsEndpoint | null; /** Pre-selected provider when creating new */ defaultProvider?: CliProvider; } type ModeType = 'provider-based' | 'direct'; // ========== Helper Functions ========== function parseConfigText( text: string, format: 'json' | 'toml' = 'json' ): { ok: true; value: string } | { ok: false; errorKey: string } { const trimmed = text.trim(); if (!trimmed) { return { ok: true, value: '' }; } if (format === 'json') { try { JSON.parse(trimmed); return { ok: true, value: trimmed }; } catch { return { ok: false, errorKey: 'invalidJson' }; } } // TOML: basic format check (no full parser needed) return { ok: true, value: trimmed }; } // ========== Main Component ========== export function CliSettingsModal({ open, onClose, cliSettings, defaultProvider }: CliSettingsModalProps) { const { formatMessage } = useIntl(); const { error } = useNotifications(); const isEditing = !!cliSettings; // Mutations const { createCliSettings, isCreating } = useCreateCliSettings(); const { updateCliSettings, isUpdating } = useUpdateCliSettings(); // Get providers for provider-based mode (Claude) const { providers } = useProviders(); // Common form state const [name, setName] = useState(''); const [description, setDescription] = useState(''); const [enabled, setEnabled] = useState(true); const [cliProvider, setCliProvider] = useState('claude'); // Claude: mode tabs const [mode, setMode] = useState('direct'); const [providerId, setProviderId] = useState(''); const [authToken, setAuthToken] = useState(''); const [baseUrl, setBaseUrl] = useState(''); const [showToken, setShowToken] = useState(false); const [settingsFile, setSettingsFile] = useState(''); // Codex specific const [codexApiKey, setCodexApiKey] = useState(''); const [codexBaseUrl, setCodexBaseUrl] = useState(''); const [codexProfile, setCodexProfile] = useState(''); const [showCodexKey, setShowCodexKey] = useState(false); const [authJson, setAuthJson] = useState(''); const [configToml, setConfigToml] = useState(''); const [writeCommonConfig, setWriteCommonConfig] = useState(false); // Gemini specific const [geminiApiKey, setGeminiApiKey] = useState(''); const [showGeminiKey, setShowGeminiKey] = useState(false); const [geminiSettingsJson, setGeminiSettingsJson] = useState(''); const [isLoadingGeminiConfig, setIsLoadingGeminiConfig] = useState(false); // Shared const [model, setModel] = useState(''); const [availableModels, setAvailableModels] = useState([]); const [modelInput, setModelInput] = useState(''); const [tags, setTags] = useState([]); const [tagInput, setTagInput] = useState(''); const [configJson, setConfigJson] = useState('{}'); const [showJsonInput, setShowJsonInput] = useState(false); const [errors, setErrors] = useState>({}); // Codex config preview loading state const [isLoadingCodexConfig, setIsLoadingCodexConfig] = useState(false); // Initialize form useEffect(() => { if (cliSettings) { setName(cliSettings.name); setDescription(cliSettings.description || ''); setEnabled(cliSettings.enabled); setCliProvider(cliSettings.provider || 'claude'); setModel(cliSettings.settings.model || ''); setAvailableModels(cliSettings.settings.availableModels || []); setTags(cliSettings.settings.tags || []); const provider = cliSettings.provider || 'claude'; if (provider === 'claude') { setSettingsFile((cliSettings.settings as any).settingsFile || ''); const env = cliSettings.settings.env; const hasCustomBaseUrl = Boolean( env.ANTHROPIC_BASE_URL && !env.ANTHROPIC_BASE_URL.includes('api.anthropic.com') ); if (hasCustomBaseUrl) { setMode('direct'); setBaseUrl(env.ANTHROPIC_BASE_URL || ''); setAuthToken(env.ANTHROPIC_AUTH_TOKEN || ''); } else { setMode('provider-based'); const matchingProvider = providers.find((p) => p.apiBase === env.ANTHROPIC_BASE_URL); if (matchingProvider) setProviderId(matchingProvider.id); } } else if (provider === 'codex') { const s = cliSettings.settings as any; setCodexApiKey(s.env.OPENAI_API_KEY || ''); setCodexBaseUrl(s.env.OPENAI_BASE_URL || ''); setCodexProfile(s.profile || ''); setAuthJson(s.authJson || ''); setConfigToml(s.configToml || ''); } else if (provider === 'gemini') { setGeminiApiKey(cliSettings.settings.env.GEMINI_API_KEY || cliSettings.settings.env.GOOGLE_API_KEY || ''); } } else { // Reset for new const p = defaultProvider || 'claude'; setName(''); setDescription(''); setEnabled(true); setCliProvider(p); setMode('direct'); setProviderId(''); setModel(''); setSettingsFile(''); setAuthToken(''); setBaseUrl(''); setCodexApiKey(''); setCodexBaseUrl(''); setCodexProfile(''); setShowCodexKey(false); setAuthJson(''); setConfigToml(''); setWriteCommonConfig(false); setGeminiApiKey(''); setShowGeminiKey(false); setGeminiSettingsJson(''); setAvailableModels([]); setModelInput(''); setTags([]); setTagInput(''); setConfigJson('{}'); setShowJsonInput(false); setErrors({}); } }, [cliSettings, open, providers, defaultProvider]); // Validate form const validateForm = (): boolean => { const newErrors: Record = {}; if (!name.trim()) { newErrors.name = formatMessage({ id: 'apiSettings.validation.nameRequired' }); } else { const namePattern = /^[a-zA-Z][a-zA-Z0-9_-]*$/; if (!namePattern.test(name.trim())) { newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameFormatHint' }); } if (name.trim().length > 32) { newErrors.name = formatMessage({ id: 'apiSettings.cliSettings.nameTooLong' }, { max: 32 }); } } if (cliProvider === 'claude') { if (mode === 'provider-based' && !providerId) { newErrors.providerId = formatMessage({ id: 'apiSettings.cliSettings.validation.providerRequired' }); } if (mode === 'direct' && !authToken.trim() && !baseUrl.trim()) { newErrors.direct = formatMessage({ id: 'apiSettings.cliSettings.validation.authOrBaseUrlRequired' }); } } if (authJson.trim()) { const parsed = parseConfigText(authJson, 'json'); if (!parsed.ok) { newErrors.authJson = formatMessage({ id: `apiSettings.cliSettings.${parsed.errorKey}` }); } } if (showJsonInput) { const parsed = parseConfigText(configJson, 'json'); if (!parsed.ok) { newErrors.configJson = formatMessage({ id: `apiSettings.cliSettings.${parsed.errorKey}` }); } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; // Handle load Codex config preview const handleLoadCodexConfig = async () => { setIsLoadingCodexConfig(true); try { const result = await fetchCodexConfigPreview(); if (result.success) { if (result.configToml) { setConfigToml(result.configToml); } if (result.authJson) { setAuthJson(result.authJson); } } else { error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config'); } } catch (err) { error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config'); } finally { setIsLoadingCodexConfig(false); } }; // Handle load Gemini config preview const handleLoadGeminiConfig = async () => { setIsLoadingGeminiConfig(true); try { const result = await fetchGeminiConfigPreview(); if (result.success) { if (result.settingsJson) { setGeminiSettingsJson(result.settingsJson); } } else { error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config'); } } catch (err) { error(formatMessage({ id: 'apiSettings.cliSettings.loadConfigError' }) || 'Failed to load config'); } finally { setIsLoadingGeminiConfig(false); } }; // Handle save const handleSave = async () => { if (!validateForm()) return; try { let settings: any; if (cliProvider === 'claude') { const env: Record = { DISABLE_AUTOUPDATER: '1' }; if (mode === 'provider-based') { const provider = providers.find((p) => p.id === providerId); if (provider?.apiBase) env.ANTHROPIC_BASE_URL = provider.apiBase; if (provider?.apiKey) env.ANTHROPIC_AUTH_TOKEN = provider.apiKey; } else { if (authToken.trim()) env.ANTHROPIC_AUTH_TOKEN = authToken.trim(); if (baseUrl.trim()) env.ANTHROPIC_BASE_URL = baseUrl.trim(); } settings = { env, model: model || undefined, settingsFile: settingsFile.trim() || undefined, availableModels, tags, }; } else if (cliProvider === 'codex') { const env: Record = {}; if (codexApiKey.trim()) env.OPENAI_API_KEY = codexApiKey.trim(); if (codexBaseUrl.trim()) env.OPENAI_BASE_URL = codexBaseUrl.trim(); settings = { env, model: model || undefined, profile: codexProfile.trim() || undefined, authJson: authJson.trim() || undefined, configToml: configToml.trim() || undefined, availableModels, tags, }; } else { // gemini const env: Record = {}; if (geminiApiKey.trim()) env.GEMINI_API_KEY = geminiApiKey.trim(); settings = { env, model: model || undefined, availableModels, tags, }; } // Merge JSON config if shown if (showJsonInput && configJson.trim()) { try { const extra = JSON.parse(configJson); if (extra && typeof extra === 'object' && !Array.isArray(extra)) { Object.assign(settings, extra); } } catch { // skip invalid json } } const request = { id: cliSettings?.id, name: name.trim(), description: description.trim() || undefined, provider: cliProvider, enabled, settings, }; if (isEditing && cliSettings) { await updateCliSettings(cliSettings.id, request); } else { await createCliSettings(request); } onClose(); } catch (err) { error(formatMessage({ id: 'apiSettings.cliSettings.saveError' })); } }; // Tag/Model helpers const handleAddModel = () => { const v = modelInput.trim(); if (v && !availableModels.includes(v)) { setAvailableModels([...availableModels, v]); setModelInput(''); } }; const handleRemoveModel = (m: string) => setAvailableModels(availableModels.filter((x) => x !== m)); const handleAddTag = () => { const v = tagInput.trim(); if (v && !tags.includes(v)) { setTags([...tags, v]); setTagInput(''); } }; const handleRemoveTag = (t: string) => setTags(tags.filter((x) => x !== t)); const predefinedTags = ['分析', 'Debug', 'implementation', 'refactoring', 'testing']; const selectedProvider = providers.find((p) => p.id === providerId); // Title by provider const providerLabel: Record = { claude: 'Claude', codex: 'Codex', gemini: 'Gemini', }; return ( {isEditing ? formatMessage({ id: 'apiSettings.cliSettings.actions.edit' }) : formatMessage({ id: 'apiSettings.cliSettings.actions.add' })} {' - '}{providerLabel[cliProvider]} {formatMessage({ id: 'apiSettings.cliSettings.modalDescription' })}
{/* Common Fields */}
{/* Provider Selector (only when creating new) */} {!isEditing && (
)}
setName(e.target.value)} placeholder={formatMessage({ id: 'apiSettings.cliSettings.namePlaceholder' })} className={errors.name ? 'border-destructive' : ''} maxLength={32} />

{formatMessage({ id: 'apiSettings.cliSettings.nameFormatHint' })}

{errors.name &&

{errors.name}

}