673 lines
29 KiB
TypeScript
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>
|
|
);
|
|
};
|
|
|