import React, { useState, useEffect, useRef } from 'react';
import { getUserConfig, updateUserConfig, exportUserConfig, importUserConfig, verifyProvider, getProviderModels, getAllProviders, getVoices } from '../services/apiService';
const SettingsPage = () => {
const [config, setConfig] = useState({ llm: {}, tts: {}, stt: {} });
const [effective, setEffective] = useState({ llm: {}, tts: {}, stt: {} });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [message, setMessage] = useState({ type: '', text: '' });
const [activeTab, setActiveTab] = useState('llm');
const [expandedProvider, setExpandedProvider] = useState(null);
const [selectedNewProvider, setSelectedNewProvider] = useState('');
const [verifying, setVerifying] = useState(null);
const [fetchedModels, setFetchedModels] = useState({});
const [providerLists, setProviderLists] = useState({ llm: [], tts: [], stt: [] });
const [providerStatuses, setProviderStatuses] = useState({});
const [voiceList, setVoiceList] = useState([]);
const [showVoicesModal, setShowVoicesModal] = useState(false);
const [voicesLoading, setVoicesLoading] = useState(false);
const fileInputRef = useRef(null);
const handleViewVoices = async (apiKey = null) => {
setShowVoicesModal(true);
// Force refresh if an explicit apiKey is provided, otherwise use cache if available
if (voiceList.length === 0 || apiKey) {
setVoicesLoading(true);
try {
const voices = await getVoices(apiKey);
setVoiceList(voices);
} catch (e) {
console.error(e);
} finally {
setVoicesLoading(false);
}
}
};
useEffect(() => {
const fetchProviders = async () => {
try {
const [llm, tts, stt] = await Promise.all([
getAllProviders('llm'),
getAllProviders('tts'),
getAllProviders('stt')
]);
setProviderLists({
llm: llm.map(id => ({ id, label: id === 'general' ? 'General (LiteLLM / Custom)' : id.charAt(0).toUpperCase() + id.slice(1) })).sort((a, b) => a.label.localeCompare(b.label)),
tts: tts.map(id => ({ id, label: id === 'general' ? 'General (LiteLLM / Custom)' : id.charAt(0).toUpperCase() + id.slice(1) })).sort((a, b) => a.label.localeCompare(b.label)),
stt: stt.map(id => ({ id, label: id === 'general' ? 'General (LiteLLM / Custom)' : (id === 'google_gemini' ? 'Google Gemini' : id.charAt(0).toUpperCase() + id.slice(1)) })).sort((a, b) => a.label.localeCompare(b.label))
});
} catch (e) {
console.error("Failed to load provider lists", e);
}
};
fetchProviders();
}, []);
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setLoading(true);
const data = await getUserConfig();
setConfig({
llm: data.preferences?.llm || {},
tts: data.preferences?.tts || {},
stt: data.preferences?.stt || {}
});
setEffective(data.effective || { llm: {}, tts: {}, stt: {} });
setMessage({ type: '', text: '' });
} catch (err) {
console.error("Error loading config:", err);
setMessage({ type: 'error', text: 'Failed to load configuration.' });
} finally {
setLoading(false);
}
};
useEffect(() => {
if (expandedProvider) {
const parts = expandedProvider.split('_');
const sectionKey = parts[0];
const providerId = parts.slice(1).join('_');
const fetchKey = `${sectionKey}_${providerId}`;
if (!fetchedModels[fetchKey]) {
getProviderModels(providerId, sectionKey).then(models => {
setFetchedModels(prev => ({ ...prev, [fetchKey]: models }));
}).catch(e => console.warn("Failed fetching models for", providerId, "section", sectionKey));
}
}
}, [expandedProvider, fetchedModels]);
const handleSave = async (e) => {
e.preventDefault();
try {
setSaving(true);
const updated = await updateUserConfig(config);
// reload after save to get latest effective config
await loadConfig();
setMessage({ type: 'success', text: 'Settings saved successfully!' });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
} catch (err) {
console.error("Error saving config:", err);
setMessage({ type: 'error', text: 'Failed to save configuration.' });
} finally {
setSaving(false);
}
};
const handleExport = async () => {
try {
const response = await exportUserConfig();
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = "config.yaml";
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
} catch (error) {
console.error("Export Error: ", error);
setMessage({ type: 'error', text: 'Failed to export YAML.' });
}
};
const handleImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
setLoading(true);
const formData = new FormData();
formData.append('file', file);
await importUserConfig(formData);
await loadConfig();
setMessage({ type: 'success', text: 'Configuration imported successfully!' });
setTimeout(() => setMessage({ type: '', text: '' }), 3000);
} catch (error) {
console.error("Import Error: ", error);
setMessage({ type: 'error', text: 'Failed to import YAML: ' + error.message });
} finally {
setLoading(false);
if (fileInputRef.current) fileInputRef.current.value = '';
}
};
const handleChange = (section, field, value, providerId = null) => {
if (field === 'providers' && providerId) {
setProviderStatuses(prev => {
const updated = { ...prev };
delete updated[`${section}_${providerId}`];
return updated;
});
}
setConfig((prev) => ({
...prev,
[section]: {
...prev[section],
[field]: value
}
}));
};
if (loading) {
return (
<div className="flex h-screen items-center justify-center dark:bg-gray-900">
<div className="text-xl text-gray-400 animate-pulse">Loading settings...</div>
</div>
);
}
const inputClass = "w-full border border-gray-300 dark:border-gray-600 rounded-lg p-3 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors duration-200 shadow-sm";
const labelClass = "block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2";
const sectionClass = "animate-fade-in";
const renderProviderSection = (sectionKey, providerDefs, allowVoice = false) => {
const activeProviderIds = new Set([
...Object.keys(config[sectionKey]?.providers || {})
]);
const activeProviders = providerDefs.filter(p => activeProviderIds.has(p.id));
const availableToAdd = providerDefs.filter(p => !activeProviderIds.has(p.id));
const currentActivePrimary = config[sectionKey]?.active_provider || effective[sectionKey]?.active_provider || '';
const handleAddProvider = () => {
if (!selectedNewProvider) return;
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[selectedNewProvider] = { api_key: '', model: '' };
handleChange(sectionKey, 'providers', newProviders);
// auto-set primary if first one
if (!currentActivePrimary) {
handleChange(sectionKey, 'active_provider', selectedNewProvider);
}
setExpandedProvider(`${sectionKey}_${selectedNewProvider}`);
setSelectedNewProvider('');
};
const handleDeleteProvider = (providerId) => {
const newProviders = { ...((config[sectionKey] && config[sectionKey].providers) || {}) };
delete newProviders[providerId];
handleChange(sectionKey, 'providers', newProviders);
if (currentActivePrimary === providerId) {
handleChange(sectionKey, 'active_provider', Object.keys(newProviders)[0] || '');
}
if (expandedProvider === `${sectionKey}_${providerId}`) setExpandedProvider(null);
};
const handleSetActive = async (providerId) => {
// Update local state immediately for responsive UI
handleChange(sectionKey, 'active_provider', providerId);
// Persist immediately to DB so it survives refresh
try {
const newConfig = {
...config,
[sectionKey]: {
...config[sectionKey],
active_provider: providerId
}
};
await updateUserConfig(newConfig);
setMessage({ type: 'success', text: `${sectionKey.toUpperCase()} primary provider set to "${providerId}"` });
setTimeout(() => setMessage({ type: '', text: '' }), 2000);
} catch (e) {
console.error('Failed to persist active_provider', e);
}
};
const handleVerifyProvider = async (providerId, providerPrefs) => {
try {
setVerifying(`${sectionKey}_${providerId}`);
setMessage({ type: '', text: '' });
const payload = {
provider_name: providerId,
api_key: providerPrefs.api_key,
model: providerPrefs.model,
voice: providerPrefs.voice
};
const res = await verifyProvider(sectionKey, payload);
if (res.success) {
setProviderStatuses(prev => ({ ...prev, [`${sectionKey}_${providerId}`]: 'success' }));
setMessage({ type: 'success', text: `Verified ${providerId} successfully!` });
} else {
setProviderStatuses(prev => ({ ...prev, [`${sectionKey}_${providerId}`]: 'error' }));
setMessage({ type: 'error', text: `Verification failed for ${providerId}: ${res.message}` });
}
} catch (err) {
setMessage({ type: 'error', text: `Error verifying ${providerId}.` });
} finally {
setVerifying(null);
setTimeout(() => setMessage({ type: '', text: '' }), 5000);
}
};
return (
<div className="space-y-6">
<div className="flex gap-4 items-end bg-gray-50 dark:bg-gray-800 p-4 rounded-xl border border-gray-200 dark:border-gray-700">
<div className="flex-1">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">Enable New Provider</label>
<select
value={selectedNewProvider}
onChange={(e) => setSelectedNewProvider(e.target.value)}
className={inputClass}
>
<option value="">-- Select a provider --</option>
{availableToAdd.map(p => (
<option key={p.id} value={p.id}>{p.label}</option>
))}
</select>
</div>
<button
type="button"
onClick={handleAddProvider}
disabled={!selectedNewProvider}
className="px-4 py-3 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-500 rounded-lg text-sm font-semibold hover:bg-gray-50 dark:hover:bg-gray-600 disabled:opacity-50 transition-colors"
>
Add
</button>
</div>
<div className="space-y-4">
{activeProviders.length === 0 && (
<p className="text-gray-500 text-sm text-center py-4">No providers enabled. Add one above.</p>
)}
{activeProviders.map((provider) => {
const isExpanded = expandedProvider === `${sectionKey}_${provider.id}`;
const providerPrefs = config[sectionKey]?.providers?.[provider.id] || {};
const providerEff = effective[sectionKey]?.providers?.[provider.id] || {};
const isActivePrimary = currentActivePrimary === provider.id;
return (
<div key={provider.id} className={`border ${isActivePrimary ? 'border-indigo-400 dark:border-indigo-600' : 'border-gray-200 dark:border-gray-700'} rounded-lg overflow-hidden bg-white dark:bg-gray-800 shadow-sm transition-all duration-200`}>
<div
onClick={() => setExpandedProvider(isExpanded ? null : `${sectionKey}_${provider.id}`)}
className="flex justify-between items-center p-4 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
>
<h3 className="font-bold text-gray-900 dark:text-gray-100 flex items-center gap-2 max-w-[150px] sm:max-w-xs truncate">
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${providerStatuses[`${sectionKey}_${provider.id}`] === 'success' ? 'bg-emerald-500' : providerStatuses[`${sectionKey}_${provider.id}`] === 'error' ? 'bg-red-500' : 'bg-gray-300'}`}></div>
<span className="truncate">{provider.label}</span>
{isActivePrimary && (
<span className="ml-2 px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider text-indigo-700 bg-indigo-100 dark:text-indigo-300 dark:bg-indigo-900/50 rounded-full flex-shrink-0">Primary</span>
)}
</h3>
<div className="flex items-center gap-2 sm:gap-4 flex-1 justify-end overflow-hidden">
<div className="text-sm font-mono text-gray-500 hidden sm:block truncate max-w-[120px]">
{providerEff.api_key && providerEff.api_key !== 'None' ? `Key: ${providerEff.api_key}` : 'Sys Default'}
</div>
{!isActivePrimary && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSetActive(provider.id);
}}
className="text-[11px] uppercase tracking-wider text-indigo-700 bg-indigo-50 hover:bg-indigo-100 dark:bg-indigo-900/40 dark:hover:bg-indigo-900/60 dark:text-indigo-300 font-bold px-2 py-1.5 rounded transition-colors shadow-sm border border-indigo-200 dark:border-indigo-800/50"
>
Set Primary
</button>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleDeleteProvider(provider.id);
}}
className="text-xs text-red-600 bg-red-50 hover:bg-red-100 dark:bg-red-900/30 dark:hover:bg-red-900/50 dark:text-red-400 font-semibold px-2 py-1 rounded transition-colors"
>
Remove
</button>
<button
type="button"
disabled={verifying === `${sectionKey}_${provider.id}`}
onClick={(e) => {
e.stopPropagation();
handleVerifyProvider(provider.id, providerPrefs);
}}
className="text-xs text-emerald-700 bg-emerald-50 hover:bg-emerald-100 dark:bg-emerald-900/40 dark:hover:bg-emerald-900/60 dark:text-emerald-300 font-semibold px-2 py-1 rounded transition-colors"
>
{verifying === `${sectionKey}_${provider.id}` ? 'Testing...' : 'Test'}
</button>
<svg className={`w-5 h-5 flex-shrink-0 text-gray-500 transform transition-transform ${isExpanded ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
{isExpanded && (
<div className="p-4 border-t border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800/50">
<div className="mb-4 flex gap-4">
<div className="flex-1">
<label className={labelClass}>API Key</label>
<input
type={providerPrefs.api_key?.includes('***') ? 'text' : 'password'}
value={providerPrefs.api_key || ''}
onChange={(e) => {
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = { ...providerPrefs, api_key: e.target.value };
handleChange(sectionKey, 'providers', newProviders, provider.id);
}}
onFocus={(e) => {
// Auto-clear masked string on focus so they can start typing real key cleanly
if (e.target.value.includes('***')) {
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = { ...providerPrefs, api_key: '' };
handleChange(sectionKey, 'providers', newProviders, provider.id);
}
}}
placeholder="sk-..."
className={inputClass}
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">Specify your API key for {provider.label}.</p>
</div>
</div>
{!(sectionKey === 'tts' && provider.id === 'gcloud_tts') && (
<div className="mb-4">
<label className={labelClass}>Model Selection</label>
{fetchedModels[`${sectionKey}_${provider.id}`] && fetchedModels[`${sectionKey}_${provider.id}`].length > 0 ? (
<select
value={providerPrefs.model || ''}
onChange={(e) => {
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = { ...providerPrefs, model: e.target.value };
handleChange(sectionKey, 'providers', newProviders, provider.id);
}}
className={inputClass}
>
<option value="">-- Let Backend Decide Default --</option>
{fetchedModels[`${sectionKey}_${provider.id}`].map(m => (
<option key={m.model_name} value={m.model_name}>
{m.model_name} {m.max_input_tokens ? `(Context: ${Math.round(m.max_input_tokens / 1000)}k)` : ''}
</option>
))}
</select>
) : (
<input
type="text"
value={providerPrefs.model || ''}
onChange={(e) => {
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = { ...providerPrefs, model: e.target.value };
handleChange(sectionKey, 'providers', newProviders, provider.id);
}}
placeholder={provider.id === 'general' ? "E.g. vertex_ai/gemini-1.5-flash" : (sectionKey === 'llm' ? "E.g. gpt-4, claude-3-opus" : "E.g. whisper-1, gemini-1.5-flash")}
className={inputClass}
/>
)}
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Specify exactly which model to pass to the provider API. Active default: <span className="font-mono text-gray-700 dark:text-gray-300">{providerEff.model || 'None'}</span>
</p>
</div>
)}
{allowVoice && (
<div className="mb-4">
<div className="flex items-center justify-between mb-2">
<label className={labelClass} style={{ margin: 0 }}>Voice Name</label>
<button type="button" onClick={() => handleViewVoices(providerPrefs.api_key)} className="flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 dark:bg-blue-900/60 text-blue-600 dark:text-blue-400 text-xs font-bold hover:bg-blue-200 dark:hover:bg-blue-800 transition-colors shadow-sm" title="View available voices">?</button>
</div>
<input
type="text"
value={providerPrefs.voice || ''}
onChange={(e) => {
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = { ...providerPrefs, voice: e.target.value };
handleChange(sectionKey, 'providers', newProviders, provider.id);
}}
placeholder="E.g., Kore, en-US-Journey-F"
className={inputClass}
/>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Active default: <span className="font-mono text-gray-700 dark:text-gray-300">{providerEff.voice || 'None'}</span>
</p>
</div>
)}
{/* Custom Parameters Section */}
<div className="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
<label className={`${labelClass} flex items-center gap-2`}>
Custom Parameters
<span className="text-[10px] font-normal normal-case text-gray-400">(e.g., vertex_project, vertex_location)</span>
</label>
<div className="space-y-2 mb-3">
{Object.entries(providerPrefs).map(([key, value]) => {
// Filter out standard fields to only show "custom" ones here
if (['api_key', 'model', 'voice'].includes(key)) return null;
return (
<div key={key} className="flex gap-2 items-center animate-in fade-in slide-in-from-left-2 duration-200">
<div className="flex-1 bg-gray-50 dark:bg-gray-800/50 p-2 rounded-lg border border-gray-100 dark:border-gray-700 font-mono text-sm flex justify-between items-center group">
<span className="text-indigo-600 dark:text-indigo-400 font-bold">{key}:</span>
<span className="truncate ml-2 text-gray-600 dark:text-gray-300">{value}</span>
</div>
<button
type="button"
onClick={() => {
const { [key]: deleted, ...rest } = providerPrefs;
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = rest;
handleChange(sectionKey, 'providers', newProviders, provider.id);
}}
className="p-2 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-lg transition-colors"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg>
</button>
</div>
);
})}
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-[2] min-w-0 sm:min-w-[150px]">
<input
id={`new-key-${sectionKey}-${provider.id}`}
placeholder="Key (e.g. project_id)"
className={`${inputClass} !py-3 !text-sm w-full`}
/>
</div>
<div className="flex-[3] min-w-0 sm:min-w-[200px]">
<input
id={`new-val-${sectionKey}-${provider.id}`}
placeholder="Value"
className={`${inputClass} !py-3 !text-sm w-full`}
/>
</div>
<button
type="button"
onClick={() => {
const k = document.getElementById(`new-key-${sectionKey}-${provider.id}`).value.trim();
const v = document.getElementById(`new-val-${sectionKey}-${provider.id}`).value.trim();
if (k && v) {
const newProviders = { ...(config[sectionKey]?.providers || {}) };
newProviders[provider.id] = { ...providerPrefs, [k]: v };
handleChange(sectionKey, 'providers', newProviders, provider.id);
document.getElementById(`new-key-${sectionKey}-${provider.id}`).value = '';
document.getElementById(`new-val-${sectionKey}-${provider.id}`).value = '';
}
}}
className="px-6 py-3 bg-indigo-50 dark:bg-indigo-900/40 text-indigo-700 dark:text-indigo-300 rounded-xl text-sm font-bold hover:bg-indigo-100 dark:hover:bg-indigo-900/60 transition-all shadow-sm border border-indigo-200 dark:border-indigo-800/50 flex-shrink-0"
>
Add Parameter
</button>
</div>
</div>
</div>
)}
</div>
);
})}
</div>
</div>
);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900 pt-20 px-4 sm:px-6 lg:px-8 font-sans">
<div className="max-w-3xl mx-auto">
<div className="flex justify-between items-end mb-2">
<h1 className="text-3xl font-extrabold text-gray-900 dark:text-white tracking-tight">Configuration</h1>
<div className="flex gap-2">
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
accept=".yaml,.yml"
onChange={handleImport}
/>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="text-sm font-semibold px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 transition-colors shadow-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-8l-4-4m0 0L8 8m4-4v12" /></svg>
Import
</button>
<button
type="button"
onClick={handleExport}
className="text-sm font-semibold px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-800 text-gray-700 dark:text-gray-300 transition-colors shadow-sm flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 16v1a2 2 0 002 2h12a2 2 0 002-2v-1m-4-4l-4 4m0 0l-4-4m4 4V4" /></svg>
Export
</button>
</div>
</div>
<p className="text-gray-600 dark:text-gray-400 mb-8">
Customize your AI models, backend API tokens, and providers. These settings override system defaults.
</p>
{message.text && (
<div className={`p-4 rounded-xl mb-6 shadow-sm border ${message.type === 'error' ? 'bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-400 border-red-200 dark:border-red-800' : 'bg-emerald-50 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-400 border-emerald-200 dark:border-emerald-800'}`}>
{message.text}
</div>
)}
<div className="bg-white dark:bg-gray-800 rounded-2xl shadow-xl overflow-hidden border border-gray-100 dark:border-gray-700 backdrop-blur-sm">
{/* Tabs */}
<div className="flex border-b border-gray-200 dark:border-gray-700">
{['llm', 'tts', 'stt'].map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`flex-1 py-4 text-sm font-bold uppercase tracking-wider transition-colors duration-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 ${activeTab === tab
? 'text-indigo-600 dark:text-indigo-400 border-b-2 border-indigo-600 dark:border-indigo-400 bg-indigo-50/50 dark:bg-indigo-900/10'
: 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700/50'
}`}
>
{tab === 'llm' ? 'Language Models' : tab === 'tts' ? 'Text-to-Speech' : 'Speech-to-Text'}
</button>
))}
</div>
<form onSubmit={handleSave} className="p-6 sm:p-8 space-y-6">
{/* LLM Settings */}
{activeTab === 'llm' && (
<div className={sectionClass}>
{renderProviderSection('llm', providerLists.llm, false)}
</div>
)}
{/* TTS Settings */}
{activeTab === 'tts' && (
<div className={sectionClass}>
{renderProviderSection('tts', providerLists.tts, true)}
</div>
)}
{/* STT Settings */}
{activeTab === 'stt' && (
<div className={sectionClass}>
{renderProviderSection('stt', providerLists.stt, false)}
</div>
)}
<div className="pt-6 mt-6 border-t border-gray-200 dark:border-gray-700 flex items-center justify-end">
<button
type="submit"
disabled={saving}
className="px-6 py-3 bg-indigo-600 hover:bg-indigo-700 text-white font-bold rounded-xl shadow-lg shadow-indigo-200 dark:shadow-indigo-900/50 transform transition duration-200 hover:scale-[1.02] focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-75 disabled:cursor-not-allowed disabled:transform-none flex items-center gap-2"
>
{saving && (
<svg className="animate-spin -ml-1 mr-2 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
)}
<span>Save Configuration</span>
</button>
</div>
</form>
</div>
</div>
{showVoicesModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 p-4 transition-opacity" onClick={() => setShowVoicesModal(false)}>
<div className="bg-white dark:bg-gray-800 rounded-xl max-w-lg w-full p-6 shadow-2xl relative max-h-[85vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex justify-between items-center mb-4 border-b border-gray-100 dark:border-gray-700 pb-3">
<div>
<h3 className="text-xl font-bold text-gray-900 dark:text-gray-100">Available Cloud Voices</h3>
<p className="text-xs text-gray-500 mt-1">Found {voiceList.length} voices to choose from.</p>
<p className="text-xs text-indigo-500 font-medium mt-1">Highlighted voices (Chirp, Journey, Studio) use advanced AI for highest quality.</p>
</div>
<button onClick={() => setShowVoicesModal(false)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-3xl font-bold focus:outline-none">×</button>
</div>
<div className="overflow-y-auto flex-1 bg-gray-50 dark:bg-gray-900/50 border border-gray-100 dark:border-gray-700 p-2 rounded-lg">
{voicesLoading ? (
<div className="flex h-32 items-center justify-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 dark:border-indigo-400"></div>
</div>
) : voiceList.length > 0 ? (
<ul className="space-y-[2px]">
{voiceList.map((v, i) => {
let highlight = v.toLowerCase().includes('chirp') || v.toLowerCase().includes('journey') || v.toLowerCase().includes('studio');
return (
<li key={i} className={`text-sm cursor-text font-mono select-all p-2 hover:bg-white dark:hover:bg-gray-800 rounded transition-colors border border-transparent hover:border-gray-200 dark:hover:border-gray-600 ${highlight ? 'text-indigo-600 dark:text-indigo-400 font-semibold' : 'text-gray-600 dark:text-gray-400'}`}>
{v}
</li>
)
})}
</ul>
) : (
<p className="text-center text-gray-500 mt-8">No voices found. Make sure your API key is configured and valid.</p>
)}
</div>
<div className="mt-4 text-xs text-gray-500 dark:text-gray-400 flex justify-between items-center">
<span>Double-click a name to select it, then paste it into the field.</span>
<button onClick={() => setShowVoicesModal(false)} className="px-4 py-2 bg-gray-100 hover:bg-gray-200 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-700 dark:text-gray-300 rounded-lg transition-colors font-medium">Close</button>
</div>
</div>
</div>
)}
</div>
);
};
export default SettingsPage;