Files
GitNexus/src/components/SettingsPanel.tsx
T
2026-01-12 21:19:53 +05:30

673 lines
29 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { X, Key, Server, Brain, Check, AlertCircle, Eye, EyeOff, RefreshCw, Loader2 } from 'lucide-react';
import {
loadSettings,
saveSettings,
getProviderDisplayName,
getAvailableModels,
} from '../core/llm/settings-service';
import type { LLMSettings, LLMProvider } from '../core/llm/types';
interface SettingsPanelProps {
isOpen: boolean;
onClose: () => void;
onSettingsSaved?: () => void;
}
/**
* Fetch available Gemini models from the API
*/
const fetchGeminiModels = async (apiKey: string): Promise<string[]> => {
try {
const response = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const data = await response.json();
// Filter for chat-capable models and extract model names
const models = (data.models || [])
.filter((model: any) => {
const methods = model.supportedGenerationMethods || [];
return methods.includes('generateContent');
})
.map((model: any) => {
const name = model.name || '';
return name.replace('models/', '');
})
.filter((name: string) => name.length > 0)
.sort((a: string, b: string) => {
const score = (n: string) => {
if (n.includes('gemini-2')) return 0;
if (n.includes('gemini-1.5')) return 1;
if (n.includes('gemini-1')) return 2;
return 3;
};
return score(a) - score(b) || a.localeCompare(b);
});
return models;
} catch (error) {
console.warn('Failed to fetch Gemini models:', error);
return [];
}
};
export const SettingsPanel = ({ isOpen, onClose, onSettingsSaved }: SettingsPanelProps) => {
const [settings, setSettings] = useState<LLMSettings>(loadSettings);
const [showApiKey, setShowApiKey] = useState<Record<string, boolean>>({});
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
// Gemini model fetching state
const [geminiModels, setGeminiModels] = useState<string[]>([]);
const [isLoadingModels, setIsLoadingModels] = useState(false);
const [modelFetchError, setModelFetchError] = useState<string | null>(null);
const [useCustomModel, setUseCustomModel] = useState(false);
const [useCustomAnthropicModel, setUseCustomAnthropicModel] = useState(false);
// Load settings when panel opens
useEffect(() => {
if (isOpen) {
setSettings(loadSettings());
setSaveStatus('idle');
setGeminiModels([]);
setModelFetchError(null);
setUseCustomModel(false);
setUseCustomAnthropicModel(false);
}
}, [isOpen]);
// Auto-fetch models when Gemini API key changes
const fetchModels = useCallback(async (apiKey: string) => {
if (!apiKey || apiKey.length < 10) {
setGeminiModels([]);
return;
}
setIsLoadingModels(true);
setModelFetchError(null);
const models = await fetchGeminiModels(apiKey);
setIsLoadingModels(false);
if (models.length > 0) {
setGeminiModels(models);
setModelFetchError(null);
} else {
setGeminiModels([]);
setModelFetchError('Could not fetch models. Check your API key or enter model manually.');
}
}, []);
// Fetch models when API key is entered (debounced)
useEffect(() => {
if (settings.activeProvider === 'gemini' && settings.gemini?.apiKey) {
const apiKey = settings.gemini.apiKey ?? '';
const timer = setTimeout(() => {
fetchModels(apiKey);
}, 500);
return () => clearTimeout(timer);
}
}, [settings.gemini?.apiKey, settings.activeProvider, fetchModels]);
const handleProviderChange = (provider: LLMProvider) => {
setSettings(prev => ({ ...prev, activeProvider: provider }));
};
const handleSave = () => {
try {
saveSettings(settings);
setSaveStatus('saved');
onSettingsSaved?.();
setTimeout(() => setSaveStatus('idle'), 2000);
} catch {
setSaveStatus('error');
}
};
const toggleApiKeyVisibility = (key: string) => {
setShowApiKey(prev => ({ ...prev, [key]: !prev[key] }));
};
if (!isOpen) return null;
const providers: LLMProvider[] = ['openai', 'gemini', 'anthropic', 'azure-openai'];
const availableGeminiModels = geminiModels.length > 0
? geminiModels
: getAvailableModels('gemini');
const currentGeminiModel = settings.gemini?.model ?? 'gemini-2.0-flash';
const isCustomModelSelected = !availableGeminiModels.includes(currentGeminiModel);
const availableAnthropicModels = getAvailableModels('anthropic');
const currentAnthropicModel = settings.anthropic?.model ?? 'claude-sonnet-4-20250514';
const isCustomAnthropicModelSelected = !availableAnthropicModels.includes(currentAnthropicModel);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative bg-surface border border-border-subtle rounded-2xl shadow-2xl max-w-lg w-full mx-4 overflow-hidden max-h-[90vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-border-subtle bg-elevated/50">
<div className="flex items-center gap-3">
<div className="w-10 h-10 flex items-center justify-center bg-accent/20 rounded-xl">
<Brain className="w-5 h-5 text-accent" />
</div>
<div>
<h2 className="text-lg font-semibold text-text-primary">AI Settings</h2>
<p className="text-xs text-text-muted">Configure your LLM provider</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 text-text-muted hover:text-text-primary hover:bg-hover rounded-lg transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Provider Selection */}
<div className="space-y-3">
<label className="block text-sm font-medium text-text-secondary">
Provider
</label>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{providers.map(provider => (
<button
key={provider}
onClick={() => handleProviderChange(provider)}
className={`
flex items-center gap-3 p-4 rounded-xl border-2 transition-all
${settings.activeProvider === provider
? 'border-accent bg-accent/10 text-text-primary'
: 'border-border-subtle bg-elevated hover:border-accent/50 text-text-secondary'
}
`}
>
<div className={`
w-8 h-8 rounded-lg flex items-center justify-center text-lg
${settings.activeProvider === provider ? 'bg-accent/20' : 'bg-surface'}
`}>
{provider === 'openai' ? '🤖' : provider === 'gemini' ? '💎' : provider === 'anthropic' ? '🧠' : '☁️'}
</div>
<span className="font-medium">{getProviderDisplayName(provider)}</span>
</button>
))}
</div>
</div>
{/* OpenAI Settings */}
{settings.activeProvider === 'openai' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['openai'] ? 'text' : 'password'}
value={settings.openai?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, apiKey: e.target.value }
}))}
placeholder="Enter your OpenAI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('openai')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['openai'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://platform.openai.com/api-keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
OpenAI Platform
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<select
value={settings.openai?.model ?? 'gpt-4o'}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
>
{getAvailableModels('openai').map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
Base URL <span className="text-text-muted font-normal">(optional)</span>
</label>
<input
type="url"
value={settings.openai?.baseUrl ?? ''}
onChange={e => setSettings(prev => ({
...prev,
openai: { ...prev.openai!, baseUrl: e.target.value }
}))}
placeholder="https://api.openai.com/v1 (default)"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<p className="text-xs text-text-muted">
Leave empty to use the default OpenAI API. Set a custom URL for proxies or compatible APIs.
</p>
</div>
</div>
)}
{/* Gemini Settings */}
{settings.activeProvider === 'gemini' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['gemini'] ? 'text' : 'password'}
value={settings.gemini?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, apiKey: e.target.value }
}))}
placeholder="Enter your Google AI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('gemini')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['gemini'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://aistudio.google.com/app/apikey"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Google AI Studio
</a>
</p>
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-text-secondary">Model</label>
<div className="flex items-center gap-2">
{isLoadingModels && (
<span className="flex items-center gap-1 text-xs text-text-muted">
<Loader2 className="w-3 h-3 animate-spin" />
Fetching models...
</span>
)}
{geminiModels.length > 0 && (
<span className="text-xs text-green-400">
{geminiModels.length} models available
</span>
)}
{settings.gemini?.apiKey && !isLoadingModels && (
<button
type="button"
onClick={() => fetchModels(settings.gemini?.apiKey ?? '')}
className="p-1 text-text-muted hover:text-text-primary transition-colors"
title="Refresh models"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
</div>
{/* Model selector or manual input */}
{(useCustomModel || isCustomModelSelected) ? (
<div className="space-y-2">
<input
type="text"
value={settings.gemini?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: e.target.value }
}))}
placeholder="e.g., gemini-2.0-flash-exp"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => {
setUseCustomModel(false);
if (availableGeminiModels.length > 0) {
setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: availableGeminiModels[0] }
}));
}
}}
className="text-xs text-accent hover:underline"
>
← Back to model list
</button>
</div>
) : (
<div className="space-y-2">
<select
value={settings.gemini?.model ?? 'gemini-2.0-flash'}
onChange={e => setSettings(prev => ({
...prev,
gemini: { ...prev.gemini!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
>
{availableGeminiModels.map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
<button
type="button"
onClick={() => setUseCustomModel(true)}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Enter model name manually →
</button>
</div>
)}
{modelFetchError && !useCustomModel && (
<p className="text-xs text-amber-400 flex items-center gap-1">
<AlertCircle className="w-3 h-3" />
{modelFetchError}
</p>
)}
</div>
</div>
)}
{/* Anthropic Settings */}
{settings.activeProvider === 'anthropic' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['anthropic'] ? 'text' : 'password'}
value={settings.anthropic?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, apiKey: e.target.value }
}))}
placeholder="Enter your Anthropic API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('anthropic')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['anthropic'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
<p className="text-xs text-text-muted">
Get your API key from{' '}
<a
href="https://console.anthropic.com/settings/keys"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Anthropic Console
</a>
</p>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
{(useCustomAnthropicModel || isCustomAnthropicModelSelected) ? (
<div className="space-y-2">
<input
type="text"
value={settings.anthropic?.model ?? ''}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: e.target.value }
}))}
placeholder="e.g., claude-sonnet-4-20250514"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all font-mono text-sm"
/>
<button
type="button"
onClick={() => {
setUseCustomAnthropicModel(false);
if (availableAnthropicModels.length > 0) {
setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: availableAnthropicModels[0] }
}));
}
}}
className="text-xs text-accent hover:underline"
>
← Back to model list
</button>
</div>
) : (
<div className="space-y-2">
<select
value={currentAnthropicModel}
onChange={e => setSettings(prev => ({
...prev,
anthropic: { ...prev.anthropic!, model: e.target.value }
}))}
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all appearance-none cursor-pointer"
>
{availableAnthropicModels.map(model => (
<option key={model} value={model}>{model}</option>
))}
</select>
<button
type="button"
onClick={() => setUseCustomAnthropicModel(true)}
className="text-xs text-text-muted hover:text-text-primary transition-colors"
>
Enter model name manually →
</button>
</div>
)}
</div>
</div>
)}
{/* Azure OpenAI Settings */}
{settings.activeProvider === 'azure-openai' && (
<div className="space-y-4 animate-fade-in">
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Key className="w-4 h-4" />
API Key
</label>
<div className="relative">
<input
type={showApiKey['azure'] ? 'text' : 'password'}
value={settings.azureOpenAI?.apiKey ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiKey: e.target.value }
}))}
placeholder="Enter your Azure OpenAI API key"
className="w-full px-4 py-3 pr-12 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
<button
type="button"
onClick={() => toggleApiKeyVisibility('azure')}
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-text-muted hover:text-text-primary transition-colors"
>
{showApiKey['azure'] ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
</div>
<div className="space-y-2">
<label className="flex items-center gap-2 text-sm font-medium text-text-secondary">
<Server className="w-4 h-4" />
Endpoint
</label>
<input
type="url"
value={settings.azureOpenAI?.endpoint ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, endpoint: e.target.value }
}))}
placeholder="https://your-resource.openai.azure.com"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Deployment Name</label>
<input
type="text"
value={settings.azureOpenAI?.deploymentName ?? ''}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, deploymentName: e.target.value }
}))}
placeholder="e.g., gpt-4o-deployment"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">Model</label>
<input
type="text"
value={settings.azureOpenAI?.model ?? 'gpt-4o'}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, model: e.target.value }
}))}
placeholder="gpt-4o"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-text-secondary">API Version</label>
<input
type="text"
value={settings.azureOpenAI?.apiVersion ?? '2024-08-01-preview'}
onChange={e => setSettings(prev => ({
...prev,
azureOpenAI: { ...prev.azureOpenAI!, apiVersion: e.target.value }
}))}
placeholder="2024-08-01-preview"
className="w-full px-4 py-3 bg-elevated border border-border-subtle rounded-xl text-text-primary placeholder:text-text-muted focus:border-accent focus:ring-2 focus:ring-accent/20 outline-none transition-all"
/>
</div>
</div>
<p className="text-xs text-text-muted">
Configure your Azure OpenAI service in the{' '}
<a
href="https://portal.azure.com/#view/Microsoft_Azure_ProjectOxford/CognitiveServicesHub/~/OpenAI"
target="_blank"
rel="noopener noreferrer"
className="text-accent hover:underline"
>
Azure Portal
</a>
</p>
</div>
)}
{/* Privacy Note */}
<div className="p-4 bg-elevated/50 border border-border-subtle rounded-xl">
<div className="flex gap-3">
<div className="w-8 h-8 flex items-center justify-center bg-green-500/20 rounded-lg text-green-400 flex-shrink-0">
🔒
</div>
<div className="text-xs text-text-muted leading-relaxed">
<span className="text-text-secondary font-medium">Privacy:</span> Your API keys are stored only in your browser's local storage.
They're sent directly to the LLM provider when you chat. Your code never leaves your machine.
</div>
</div>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-border-subtle bg-elevated/30">
<div className="flex items-center gap-2 text-sm">
{saveStatus === 'saved' && (
<span className="flex items-center gap-1.5 text-green-400 animate-fade-in">
<Check className="w-4 h-4" />
Settings saved
</span>
)}
{saveStatus === 'error' && (
<span className="flex items-center gap-1.5 text-red-400 animate-fade-in">
<AlertCircle className="w-4 h-4" />
Failed to save
</span>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-text-secondary hover:text-text-primary transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
className="px-5 py-2 bg-accent text-white text-sm font-medium rounded-lg hover:bg-accent-dim transition-colors"
>
Save Settings
</button>
</div>
</div>
</div>
</div>
);
};