muthuk1's picture
Convert OpenMAIC from Next.js to React (Vite)
f56a29b verified
raw
history blame
43.4 kB
import { useState, useRef, useEffect, useCallback } from 'react';
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import {
X,
Trash2,
Box,
Settings,
CheckCircle2,
XCircle,
FileText,
Image as ImageIcon,
Film,
Search,
Volume2,
Mic,
Plus,
} from 'lucide-react';
import { useI18n } from '@/lib/hooks/use-i18n';
import { useSettingsStore } from '@/lib/store/settings';
import { toast } from 'sonner';
import { type ProviderId } from '@/lib/ai/providers';
import { PROVIDERS, MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
import { cn } from '@/lib/utils';
import { createCustomProviderSettings, getProviderTypeLabel } from './utils';
import { ProviderList } from './provider-list';
import { ProviderConfigPanel } from './provider-config-panel';
import { PDFSettings } from './pdf-settings';
import { PDF_PROVIDERS } from '@/lib/pdf/constants';
import type { PDFProviderId } from '@/lib/pdf/types';
import { ImageSettings } from './image-settings';
import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
import type { ImageProviderId } from '@/lib/media/types';
import { VideoSettings } from './video-settings';
import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
import type { VideoProviderId } from '@/lib/media/types';
import { TTSSettings } from './tts-settings';
import { TTS_PROVIDERS } from '@/lib/audio/constants';
import type { TTSProviderId } from '@/lib/audio/types';
import { ASRSettings } from './asr-settings';
import { ASR_PROVIDERS } from '@/lib/audio/constants';
import type { ASRProviderId } from '@/lib/audio/types';
import { WebSearchSettings } from './web-search-settings';
import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
import type { WebSearchProviderId } from '@/lib/web-search/types';
import { GeneralSettings } from './general-settings';
import { ModelEditDialog } from './model-edit-dialog';
import { AddProviderDialog, type NewProviderData } from './add-provider-dialog';
import { AddAudioProviderDialog, type NewAudioProviderData } from './add-audio-provider-dialog';
import { isCustomTTSProvider, isCustomASRProvider } from '@/lib/audio/types';
import type { SettingsSection, EditingModel } from '@/lib/types/settings';
// ─── Provider List Column (reusable) ───
function ProviderListColumn<T extends string>({
providers,
configs,
selectedId,
onSelect,
width,
t,
onAdd,
}: {
providers: Array<{ id: T; name: string; icon?: string }>;
configs: Record<string, { isServerConfigured?: boolean }>;
selectedId: T;
onSelect: (id: T) => void;
width: number;
t: (key: string) => string;
onAdd?: () => void;
}) {
return (
<div className="flex-shrink-0 bg-background flex flex-col" style={{ width }}>
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
{providers.map((provider) => (
<button
key={provider.id}
onClick={() => onSelect(provider.id)}
className={cn(
'w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all border text-left',
selectedId === provider.id
? 'bg-primary/5 border-primary/50 shadow-sm'
: 'border-transparent hover:bg-muted/50',
)}
>
{provider.icon ? (
<img
src={provider.icon}
alt={provider.name}
className={cn(
'w-5 h-5 rounded',
MONO_LOGO_PROVIDERS.has(provider.id) && 'dark:invert',
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="h-5 w-5 text-muted-foreground" />
)}
<span className="font-medium text-sm flex-1 truncate">{provider.name}</span>
{configs[provider.id]?.isServerConfigured && (
<span className="text-[10px] px-1 py-0 h-4 leading-4 rounded shrink-0 bg-muted text-muted-foreground">
{t('settings.serverConfigured')}
</span>
)}
</button>
))}
</div>
{onAdd && (
<div className="p-3 border-t">
<Button variant="outline" size="sm" className="w-full gap-1.5" onClick={onAdd}>
<Plus className="h-3.5 w-3.5" />
{t('settings.addProviderButton')}
</Button>
</div>
)}
</div>
);
}
// ─── Helper: get TTS/ASR provider display name ───
function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => string): string {
if (isCustomTTSProvider(providerId)) {
const cfg = useSettingsStore.getState().ttsProvidersConfig[providerId];
return cfg?.customName || providerId;
}
const names: Record<string, string> = {
'openai-tts': t('settings.providerOpenAITTS'),
'azure-tts': t('settings.providerAzureTTS'),
'glm-tts': t('settings.providerGLMTTS'),
'qwen-tts': t('settings.providerQwenTTS'),
'voxcpm-tts': t('settings.providerVoxCPMTTS'),
'doubao-tts': t('settings.providerDoubaoTTS'),
'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
'minimax-tts': t('settings.providerMiniMaxTTS'),
'browser-native-tts': t('settings.providerBrowserNativeTTS'),
};
return names[providerId] || providerId;
}
function getASRProviderName(providerId: ASRProviderId, t: (key: string) => string): string {
if (isCustomASRProvider(providerId)) {
const cfg = useSettingsStore.getState().asrProvidersConfig[providerId];
return cfg?.customName || providerId;
}
const names: Record<string, string> = {
'openai-whisper': t('settings.providerOpenAIWhisper'),
'browser-native': t('settings.providerBrowserNative'),
'qwen-asr': t('settings.providerQwenASR'),
};
return names[providerId] || providerId;
}
// ─── Image/Video provider name helpers ───
const IMAGE_PROVIDER_NAMES: Record<ImageProviderId, string> = {
seedream: 'providerSeedream',
'openai-image': 'providerOpenAIImage',
'qwen-image': 'providerQwenImage',
'nano-banana': 'providerNanoBanana',
'minimax-image': 'providerMiniMaxImage',
'grok-image': 'providerGrokImage',
};
const IMAGE_PROVIDER_ICONS: Record<ImageProviderId, string> = {
seedream: '/logos/doubao.svg',
'openai-image': '/logos/openai.svg',
'qwen-image': '/logos/bailian.svg',
'nano-banana': '/logos/gemini.svg',
'minimax-image': '/logos/minimax.svg',
'grok-image': '/logos/grok.svg',
};
const VIDEO_PROVIDER_NAMES: Record<VideoProviderId, string> = {
seedance: 'providerSeedance',
kling: 'providerKling',
veo: 'providerVeo',
sora: 'providerSora',
'minimax-video': 'providerMiniMaxVideo',
'grok-video': 'providerGrokVideo',
};
const VIDEO_PROVIDER_ICONS: Record<VideoProviderId, string> = {
seedance: '/logos/doubao.svg',
kling: '/logos/kling.svg',
veo: '/logos/gemini.svg',
sora: '/logos/openai.svg',
'minimax-video': '/logos/minimax.svg',
'grok-video': '/logos/grok.svg',
};
interface SettingsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
initialSection?: SettingsSection;
}
export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsDialogProps) {
const { t } = useI18n();
// Get settings from store
const providerId = useSettingsStore((state) => state.providerId);
const _modelId = useSettingsStore((state) => state.modelId);
const providersConfig = useSettingsStore((state) => state.providersConfig);
const pdfProviderId = useSettingsStore((state) => state.pdfProviderId);
const pdfProvidersConfig = useSettingsStore((state) => state.pdfProvidersConfig);
const webSearchProviderId = useSettingsStore((state) => state.webSearchProviderId);
const webSearchProvidersConfig = useSettingsStore((state) => state.webSearchProvidersConfig);
const imageProviderId = useSettingsStore((state) => state.imageProviderId);
const imageProvidersConfig = useSettingsStore((state) => state.imageProvidersConfig);
const videoProviderId = useSettingsStore((state) => state.videoProviderId);
const videoProvidersConfig = useSettingsStore((state) => state.videoProvidersConfig);
const ttsProviderId = useSettingsStore((state) => state.ttsProviderId);
const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig);
const asrProviderId = useSettingsStore((state) => state.asrProviderId);
const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig);
// Store actions
const setModel = useSettingsStore((state) => state.setModel);
const setProviderConfig = useSettingsStore((state) => state.setProviderConfig);
const setProvidersConfig = useSettingsStore((state) => state.setProvidersConfig);
const setTTSProvider = useSettingsStore((state) => state.setTTSProvider);
const setASRProvider = useSettingsStore((state) => state.setASRProvider);
// Navigation
const [activeSection, setActiveSection] = useState<SettingsSection>('providers');
const [selectedProviderId, setSelectedProviderId] = useState<ProviderId>(providerId);
const [selectedPdfProviderId, setSelectedPdfProviderId] = useState<PDFProviderId>(pdfProviderId);
const [selectedWebSearchProviderId, setSelectedWebSearchProviderId] =
useState<WebSearchProviderId>(webSearchProviderId);
const [selectedImageProviderId, setSelectedImageProviderId] =
useState<ImageProviderId>(imageProviderId);
const [selectedVideoProviderId, setSelectedVideoProviderId] =
useState<VideoProviderId>(videoProviderId);
// Navigate to initialSection when dialog opens
useEffect(() => {
if (open && initialSection) {
// eslint-disable-next-line react-hooks/set-state-in-effect -- Sync section from prop when dialog opens
setActiveSection(initialSection);
}
}, [open, initialSection]);
// Model editing state
const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
const [showModelDialog, setShowModelDialog] = useState(false);
// Provider deletion confirmation
const [providerToDelete, setProviderToDelete] = useState<ProviderId | null>(null);
// Add provider dialog
const [showAddProviderDialog, setShowAddProviderDialog] = useState(false);
const [showAddTTSProviderDialog, setShowAddTTSProviderDialog] = useState(false);
const [showAddASRProviderDialog, setShowAddASRProviderDialog] = useState(false);
const addCustomTTSProvider = useSettingsStore((state) => state.addCustomTTSProvider);
const addCustomASRProvider = useSettingsStore((state) => state.addCustomASRProvider);
const handleAddTTSProvider = (data: NewAudioProviderData) => {
const id = `custom-tts-${Date.now()}` as TTSProviderId;
addCustomTTSProvider(id, data.name, data.baseUrl, data.requiresApiKey, data.defaultModel);
};
const handleAddASRProvider = (data: NewAudioProviderData) => {
const id = `custom-asr-${Date.now()}` as ASRProviderId;
addCustomASRProvider(id, data.name, data.baseUrl, data.requiresApiKey);
};
// Save status indicator
const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
// Resizable column widths
const [sidebarWidth, setSidebarWidth] = useState(192);
const [providerListWidth, setProviderListWidth] = useState(192);
const [isResizing, setIsResizing] = useState(false);
const resizeRef = useRef<{
target: 'sidebar' | 'providerList';
startX: number;
startWidth: number;
} | null>(null);
const handleResizeStart = useCallback(
(e: React.MouseEvent, target: 'sidebar' | 'providerList') => {
e.preventDefault();
const startWidth = target === 'sidebar' ? sidebarWidth : providerListWidth;
resizeRef.current = { target, startX: e.clientX, startWidth };
setIsResizing(true);
},
[sidebarWidth, providerListWidth],
);
useEffect(() => {
if (!isResizing) return;
const handleMouseMove = (e: MouseEvent) => {
if (!resizeRef.current) return;
const { target, startX, startWidth } = resizeRef.current;
const delta = e.clientX - startX;
const newWidth = Math.max(120, Math.min(360, startWidth + delta));
if (target === 'sidebar') {
setSidebarWidth(newWidth);
} else {
setProviderListWidth(newWidth);
}
};
const handleMouseUp = () => {
resizeRef.current = null;
setIsResizing(false);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = 'none';
document.body.style.cursor = 'col-resize';
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
document.body.style.userSelect = '';
document.body.style.cursor = '';
};
}, [isResizing]);
const handleSave = () => {
onOpenChange(false);
};
const handleProviderSelect = (pid: ProviderId) => {
setSelectedProviderId(pid);
};
const handleProviderConfigChange = (
pid: ProviderId,
apiKey: string,
baseUrl: string,
requiresApiKey: boolean,
) => {
setProviderConfig(pid, {
apiKey,
baseUrl,
requiresApiKey,
});
};
const handleProviderConfigSave = () => {
setSaveStatus('saved');
setTimeout(() => setSaveStatus('idle'), 2000);
};
const selectedProvider = providersConfig[selectedProviderId]
? {
id: selectedProviderId,
name: providersConfig[selectedProviderId].name,
type: providersConfig[selectedProviderId].type,
defaultBaseUrl: providersConfig[selectedProviderId].defaultBaseUrl,
alternateBaseUrls: PROVIDERS[selectedProviderId]?.alternateBaseUrls,
icon: providersConfig[selectedProviderId].icon,
requiresApiKey: providersConfig[selectedProviderId].requiresApiKey,
models: providersConfig[selectedProviderId].models,
}
: undefined;
// Handle model editing
const handleEditModel = (pid: ProviderId, modelIndex: number) => {
const allModels = providersConfig[pid]?.models || [];
setEditingModel({
providerId: pid,
modelIndex,
model: { ...allModels[modelIndex] },
});
setShowModelDialog(true);
};
const handleAddModel = () => {
setEditingModel({
providerId: selectedProviderId,
modelIndex: null,
model: {
id: '',
name: '',
capabilities: {
streaming: true,
tools: true,
vision: false,
},
},
});
setShowModelDialog(true);
};
const handleDeleteModel = (pid: ProviderId, modelIndex: number) => {
const currentModels = providersConfig[pid]?.models || [];
const newModels = currentModels.filter((_, i) => i !== modelIndex);
setProviderConfig(pid, { models: newModels });
};
const handleAutoSaveModel = () => {
if (!editingModel) return;
const { providerId: pid, modelIndex, model } = editingModel;
if (!model.id.trim()) return;
const currentModels = providersConfig[pid]?.models || [];
let newModels: typeof currentModels;
let newModelIndex = modelIndex;
if (modelIndex === null) {
const existingIndex = currentModels.findIndex((m) => m.id === model.id);
if (existingIndex >= 0) {
newModels = [...currentModels];
newModels[existingIndex] = model;
newModelIndex = existingIndex;
} else {
newModels = [...currentModels, model];
newModelIndex = newModels.length - 1;
}
setProviderConfig(pid, { models: newModels });
setEditingModel({ ...editingModel, modelIndex: newModelIndex });
} else {
newModels = [...currentModels];
newModels[modelIndex] = model;
setProviderConfig(pid, { models: newModels });
}
};
const handleSaveModel = () => {
if (!editingModel) return;
const { providerId: pid, modelIndex, model } = editingModel;
if (!model.id.trim()) {
toast.error(t('settings.modelIdRequired'));
return;
}
const currentModels = providersConfig[pid]?.models || [];
let newModels: typeof currentModels;
if (modelIndex === null) {
newModels = [...currentModels, model];
} else {
newModels = [...currentModels];
newModels[modelIndex] = model;
}
setProviderConfig(pid, { models: newModels });
setShowModelDialog(false);
setEditingModel(null);
};
// Handle provider management
const handleAddProvider = (providerData: NewProviderData) => {
if (!providerData.name.trim()) {
toast.error(t('settings.providerNameRequired'));
return;
}
const newProviderId = `custom-${Date.now()}` as ProviderId;
const updatedConfig = {
...providersConfig,
[newProviderId]: createCustomProviderSettings(providerData),
};
setProvidersConfig(updatedConfig);
setShowAddProviderDialog(false);
setSelectedProviderId(newProviderId);
};
const handleDeleteProvider = (pid: ProviderId) => {
if (providersConfig[pid]?.isBuiltIn) {
toast.error(t('settings.cannotDeleteBuiltIn'));
return;
}
setProviderToDelete(pid);
};
const confirmDeleteProvider = () => {
if (!providerToDelete) return;
const pid = providerToDelete;
const updatedConfig = { ...providersConfig };
delete updatedConfig[pid];
setProvidersConfig(updatedConfig);
if (selectedProviderId === pid) {
const firstRemainingPid = Object.keys(updatedConfig)[0] as ProviderId | undefined;
setSelectedProviderId(firstRemainingPid || 'openai');
}
if (providerId === pid) {
const firstRemainingPid = Object.keys(updatedConfig)[0] as ProviderId | undefined;
const firstModel = firstRemainingPid
? updatedConfig[firstRemainingPid]?.serverModels?.[0] ||
updatedConfig[firstRemainingPid]?.models?.[0]?.id
: undefined;
if (firstRemainingPid && firstModel) {
setModel(firstRemainingPid, firstModel);
} else {
setModel('openai' as ProviderId, 'gpt-5.4-mini');
}
}
setProviderToDelete(null);
};
const handleResetProvider = (pid: ProviderId) => {
const provider = PROVIDERS[pid];
if (!provider) return;
setProviderConfig(pid, { models: [...provider.models] });
toast.success(t('settings.resetSuccess'));
};
// Get all providers from providersConfig
const allProviders = Object.entries(providersConfig).map(([id, config]) => ({
id: id as ProviderId,
name: config.name,
type: config.type,
defaultBaseUrl: config.defaultBaseUrl,
icon: config.icon,
requiresApiKey: config.requiresApiKey,
models: config.models,
isServerConfigured: config.isServerConfigured,
}));
// Sections that show a provider list column
const _hasProviderList = [
'providers',
'pdf',
'web-search',
'image',
'video',
'tts',
'asr',
].includes(activeSection);
// Get header content based on section
const getHeaderContent = () => {
switch (activeSection) {
case 'general':
return <h2 className="text-lg font-semibold">{t('settings.systemSettings')}</h2>;
case 'providers':
if (selectedProvider) {
return (
<>
{selectedProvider.icon ? (
<img
src={selectedProvider.icon}
alt={selectedProvider.name}
className={cn(
'w-8 h-8 rounded',
MONO_LOGO_PROVIDERS.has(selectedProvider.id) && 'dark:invert',
)}
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="h-8 w-8 text-muted-foreground" />
)}
<div>
<h2 className="text-lg font-semibold">
{t(`settings.providerNames.${selectedProvider.id}`) !==
`settings.providerNames.${selectedProvider.id}`
? t(`settings.providerNames.${selectedProvider.id}`)
: selectedProvider.name}
</h2>
<p className="text-xs text-muted-foreground">
{getProviderTypeLabel(selectedProvider.type, t)}
</p>
</div>
</>
);
}
return null;
case 'pdf': {
const pdfProvider = PDF_PROVIDERS[selectedPdfProviderId];
if (!pdfProvider) return null;
return (
<>
{pdfProvider.icon ? (
<img
src={pdfProvider.icon}
alt={pdfProvider.name}
className="w-8 h-8 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="h-8 w-8 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">{pdfProvider.name}</h2>
</>
);
}
case 'web-search': {
const wsProvider = WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId];
if (!wsProvider) return null;
return (
<>
{wsProvider.icon ? (
<img
src={wsProvider.icon}
alt={wsProvider.name}
className="w-8 h-8 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="h-8 w-8 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">{wsProvider.name}</h2>
</>
);
}
case 'image': {
const imgProvider = IMAGE_PROVIDERS[selectedImageProviderId];
const imgIcon = IMAGE_PROVIDER_ICONS[selectedImageProviderId];
return (
<>
{imgIcon ? (
<img
src={imgIcon}
alt={imgProvider?.name}
className="w-8 h-8 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="h-8 w-8 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">
{t(`settings.${IMAGE_PROVIDER_NAMES[selectedImageProviderId]}`) || imgProvider?.name}
</h2>
</>
);
}
case 'video': {
const vidProvider = VIDEO_PROVIDERS[selectedVideoProviderId];
const vidIcon = VIDEO_PROVIDER_ICONS[selectedVideoProviderId];
return (
<>
{vidIcon ? (
<img
src={vidIcon}
alt={vidProvider?.name}
className="w-8 h-8 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Box className="h-8 w-8 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">
{t(`settings.${VIDEO_PROVIDER_NAMES[selectedVideoProviderId]}`) || vidProvider?.name}
</h2>
</>
);
}
case 'tts': {
const ttsIcon = TTS_PROVIDERS[ttsProviderId as keyof typeof TTS_PROVIDERS]?.icon;
return (
<>
{ttsIcon ? (
<img
src={ttsIcon}
alt=""
className="w-8 h-8 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Volume2 className="h-6 w-6 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">{getTTSProviderName(ttsProviderId, t)}</h2>
</>
);
}
case 'asr': {
const asrIcon = ASR_PROVIDERS[asrProviderId as keyof typeof ASR_PROVIDERS]?.icon;
return (
<>
{asrIcon ? (
<img
src={asrIcon}
alt=""
className="w-8 h-8 rounded"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
) : (
<Mic className="h-6 w-6 text-muted-foreground" />
)}
<h2 className="text-lg font-semibold">{getASRProviderName(asrProviderId, t)}</h2>
</>
);
}
default:
return null;
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="h-[85vh] p-0 gap-0 block" showCloseButton={false}>
<DialogTitle className="sr-only">{t('settings.title')}</DialogTitle>
<DialogDescription className="sr-only">{t('settings.description')}</DialogDescription>
<div className="flex h-full overflow-hidden">
{/* Left Sidebar - Navigation */}
<div className="flex-shrink-0 bg-muted/30 p-3 space-y-1" style={{ width: sidebarWidth }}>
<button
onClick={() => setActiveSection('providers')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'providers'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Box className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.providers')}</span>
</button>
<button
onClick={() => setActiveSection('image')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'image'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<ImageIcon className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.imageSettings')}</span>
</button>
<button
onClick={() => setActiveSection('video')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'video'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Film className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.videoSettings')}</span>
</button>
<button
onClick={() => setActiveSection('tts')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'tts'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Volume2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.ttsSettings')}</span>
</button>
<button
onClick={() => setActiveSection('asr')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'asr'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Mic className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.asrSettings')}</span>
</button>
<button
onClick={() => setActiveSection('pdf')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'pdf'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.pdfSettings')}</span>
</button>
<button
onClick={() => setActiveSection('web-search')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'web-search'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Search className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.webSearchSettings')}</span>
</button>
<button
onClick={() => setActiveSection('general')}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
activeSection === 'general'
? 'bg-primary/10 text-primary font-medium'
: 'hover:bg-muted',
)}
>
<Settings className="h-4 w-4 shrink-0" />
<span className="truncate">{t('settings.systemSettings')}</span>
</button>
</div>
{/* Sidebar resize handle */}
<div
onMouseDown={(e) => handleResizeStart(e, 'sidebar')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
{/* Middle - Provider List (only shown for provider-based sections) */}
{activeSection === 'providers' && (
<>
<ProviderList
providers={allProviders}
selectedProviderId={selectedProviderId}
onSelect={handleProviderSelect}
onAddProvider={() => setShowAddProviderDialog(true)}
width={providerListWidth}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{activeSection === 'pdf' && (
<>
<ProviderListColumn
providers={Object.values(PDF_PROVIDERS)}
configs={pdfProvidersConfig}
selectedId={selectedPdfProviderId}
onSelect={setSelectedPdfProviderId}
width={providerListWidth}
t={t}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{activeSection === 'web-search' && (
<>
<ProviderListColumn
providers={Object.values(WEB_SEARCH_PROVIDERS)}
configs={webSearchProvidersConfig}
selectedId={selectedWebSearchProviderId}
onSelect={setSelectedWebSearchProviderId}
width={providerListWidth}
t={t}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{activeSection === 'image' && (
<>
<ProviderListColumn
providers={Object.values(IMAGE_PROVIDERS).map((p) => ({
id: p.id,
name: t(`settings.${IMAGE_PROVIDER_NAMES[p.id]}`) || p.name,
icon: IMAGE_PROVIDER_ICONS[p.id],
}))}
configs={imageProvidersConfig}
selectedId={selectedImageProviderId}
onSelect={setSelectedImageProviderId}
width={providerListWidth}
t={t}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{activeSection === 'video' && (
<>
<ProviderListColumn
providers={Object.values(VIDEO_PROVIDERS).map((p) => ({
id: p.id,
name: t(`settings.${VIDEO_PROVIDER_NAMES[p.id]}`) || p.name,
icon: VIDEO_PROVIDER_ICONS[p.id],
}))}
configs={videoProvidersConfig}
selectedId={selectedVideoProviderId}
onSelect={setSelectedVideoProviderId}
width={providerListWidth}
t={t}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{activeSection === 'tts' && (
<>
<ProviderListColumn
providers={[
...Object.values(TTS_PROVIDERS).map((p) => ({
id: p.id,
name: getTTSProviderName(p.id, t),
icon: p.icon,
})),
...Object.entries(ttsProvidersConfig)
.filter(([id]) => isCustomTTSProvider(id))
.map(([id, cfg]) => ({
id: id as TTSProviderId,
name: cfg.customName || id,
icon: undefined,
})),
]}
configs={ttsProvidersConfig}
selectedId={ttsProviderId}
onSelect={setTTSProvider}
width={providerListWidth}
t={t}
onAdd={() => setShowAddTTSProviderDialog(true)}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{activeSection === 'asr' && (
<>
<ProviderListColumn
providers={[
...Object.values(ASR_PROVIDERS).map((p) => ({
id: p.id,
name: getASRProviderName(p.id, t),
icon: p.icon,
})),
...Object.entries(asrProvidersConfig)
.filter(([id]) => isCustomASRProvider(id))
.map(([id, cfg]) => ({
id: id as ASRProviderId,
name: cfg.customName || id,
icon: undefined,
})),
]}
configs={asrProvidersConfig}
selectedId={asrProviderId}
onSelect={setASRProvider}
width={providerListWidth}
t={t}
onAdd={() => setShowAddASRProviderDialog(true)}
/>
<div
onMouseDown={(e) => handleResizeStart(e, 'providerList')}
className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
>
<div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
</div>
</>
)}
{/* Right - Configuration Panel */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Header */}
<div className="flex items-center justify-between p-5 border-b">
<div className="flex items-center gap-3">{getHeaderContent()}</div>
<div className="flex items-center gap-2">
{activeSection === 'providers' &&
!providersConfig[selectedProviderId]?.isBuiltIn && (
<Button
variant="ghost"
size="sm"
className="h-7 px-2 text-destructive hover:text-destructive"
onClick={() => handleDeleteProvider(selectedProviderId)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
<Button variant="ghost" size="icon" onClick={() => onOpenChange(false)}>
<X className="h-4 w-4" />
</Button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5">
{activeSection === 'general' && <GeneralSettings />}
{activeSection === 'providers' && selectedProvider && (
<ProviderConfigPanel
provider={selectedProvider}
initialApiKey={providersConfig[selectedProviderId]?.apiKey || ''}
initialBaseUrl={providersConfig[selectedProviderId]?.baseUrl || ''}
initialRequiresApiKey={
providersConfig[selectedProviderId]?.requiresApiKey ?? true
}
providersConfig={providersConfig}
onConfigChange={(apiKey, baseUrl, requiresApiKey) =>
handleProviderConfigChange(selectedProviderId, apiKey, baseUrl, requiresApiKey)
}
onSave={handleProviderConfigSave}
onEditModel={(index) => handleEditModel(selectedProviderId, index)}
onDeleteModel={(index) => handleDeleteModel(selectedProviderId, index)}
onAddModel={handleAddModel}
onResetToDefault={() => handleResetProvider(selectedProviderId)}
isBuiltIn={providersConfig[selectedProviderId]?.isBuiltIn ?? true}
/>
)}
{activeSection === 'pdf' && (
<PDFSettings selectedProviderId={selectedPdfProviderId} />
)}
{activeSection === 'web-search' && (
<WebSearchSettings selectedProviderId={selectedWebSearchProviderId} />
)}
{activeSection === 'image' && (
<ImageSettings selectedProviderId={selectedImageProviderId} />
)}
{activeSection === 'video' && (
<VideoSettings selectedProviderId={selectedVideoProviderId} />
)}
{activeSection === 'tts' && <TTSSettings selectedProviderId={ttsProviderId} />}
{activeSection === 'asr' && <ASRSettings selectedProviderId={asrProviderId} />}
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 px-5 py-3 border-t bg-muted/30">
{saveStatus === 'saved' && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4" />
<span>{t('settings.saveSuccess')}</span>
</div>
)}
{saveStatus === 'error' && (
<div className="flex items-center gap-1.5 text-sm text-muted-foreground">
<XCircle className="h-4 w-4" />
<span>{t('settings.saveFailed')}</span>
</div>
)}
<Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
{t('settings.close')}
</Button>
<Button size="sm" onClick={handleSave}>
{t('settings.save')}
</Button>
</div>
</div>
</div>
</DialogContent>
{/* Edit Model Dialog */}
<ModelEditDialog
open={showModelDialog}
onOpenChange={setShowModelDialog}
editingModel={editingModel}
setEditingModel={setEditingModel}
onSave={handleSaveModel}
onAutoSave={handleAutoSaveModel}
providerId={selectedProviderId}
apiKey={providersConfig[selectedProviderId]?.apiKey || ''}
baseUrl={providersConfig[selectedProviderId]?.baseUrl}
providerType={providersConfig[selectedProviderId]?.type}
requiresApiKey={providersConfig[selectedProviderId]?.requiresApiKey}
isServerConfigured={providersConfig[selectedProviderId]?.isServerConfigured}
/>
{/* Add Provider Dialog */}
<AddProviderDialog
open={showAddProviderDialog}
onOpenChange={setShowAddProviderDialog}
onAdd={handleAddProvider}
/>
{/* Add TTS Provider Dialog */}
<AddAudioProviderDialog
open={showAddTTSProviderDialog}
onOpenChange={setShowAddTTSProviderDialog}
onAdd={handleAddTTSProvider}
type="tts"
/>
{/* Add ASR Provider Dialog */}
<AddAudioProviderDialog
open={showAddASRProviderDialog}
onOpenChange={setShowAddASRProviderDialog}
onAdd={handleAddASRProvider}
type="asr"
/>
{/* Delete Provider Confirmation */}
<AlertDialog
open={providerToDelete !== null}
onOpenChange={(open) => !open && setProviderToDelete(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('settings.deleteProvider')}</AlertDialogTitle>
<AlertDialogDescription>{t('settings.deleteProviderConfirm')}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('settings.cancelEdit')}</AlertDialogCancel>
<AlertDialogAction onClick={confirmDeleteProvider}>
{t('settings.deleteProvider')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
);
}