index.tsx 39 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078
  1. 'use client';
  2. import { useState, useRef, useEffect, useCallback } from 'react';
  3. import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog';
  4. import {
  5. AlertDialog,
  6. AlertDialogAction,
  7. AlertDialogCancel,
  8. AlertDialogContent,
  9. AlertDialogDescription,
  10. AlertDialogFooter,
  11. AlertDialogHeader,
  12. AlertDialogTitle,
  13. } from '@/components/ui/alert-dialog';
  14. import { Button } from '@/components/ui/button';
  15. import {
  16. X,
  17. Trash2,
  18. Box,
  19. Settings,
  20. CheckCircle2,
  21. XCircle,
  22. FileText,
  23. Image as ImageIcon,
  24. Film,
  25. Search,
  26. Volume2,
  27. Mic,
  28. } from 'lucide-react';
  29. import { useI18n } from '@/lib/hooks/use-i18n';
  30. import { useSettingsStore } from '@/lib/store/settings';
  31. import { toast } from 'sonner';
  32. import { type ProviderId } from '@/lib/ai/providers';
  33. import { PROVIDERS, MONO_LOGO_PROVIDERS } from '@/lib/ai/providers';
  34. import { cn } from '@/lib/utils';
  35. import { getProviderTypeLabel } from './utils';
  36. import { ProviderList } from './provider-list';
  37. import { ProviderConfigPanel } from './provider-config-panel';
  38. import { PDFSettings } from './pdf-settings';
  39. import { PDF_PROVIDERS } from '@/lib/pdf/constants';
  40. import type { PDFProviderId } from '@/lib/pdf/types';
  41. import { ImageSettings } from './image-settings';
  42. import { IMAGE_PROVIDERS } from '@/lib/media/image-providers';
  43. import type { ImageProviderId } from '@/lib/media/types';
  44. import { VideoSettings } from './video-settings';
  45. import { VIDEO_PROVIDERS } from '@/lib/media/video-providers';
  46. import type { VideoProviderId } from '@/lib/media/types';
  47. import { TTSSettings } from './tts-settings';
  48. import { TTS_PROVIDERS } from '@/lib/audio/constants';
  49. import type { TTSProviderId } from '@/lib/audio/types';
  50. import { ASRSettings } from './asr-settings';
  51. import { ASR_PROVIDERS } from '@/lib/audio/constants';
  52. import type { ASRProviderId } from '@/lib/audio/types';
  53. import { WebSearchSettings } from './web-search-settings';
  54. import { WEB_SEARCH_PROVIDERS } from '@/lib/web-search/constants';
  55. import type { WebSearchProviderId } from '@/lib/web-search/types';
  56. import { GeneralSettings } from './general-settings';
  57. import { ModelEditDialog } from './model-edit-dialog';
  58. import { AddProviderDialog, type NewProviderData } from './add-provider-dialog';
  59. import type { SettingsSection, EditingModel } from '@/lib/types/settings';
  60. // ─── Provider List Column (reusable) ───
  61. function ProviderListColumn<T extends string>({
  62. providers,
  63. configs,
  64. selectedId,
  65. onSelect,
  66. width,
  67. t,
  68. }: {
  69. providers: Array<{ id: T; name: string; icon?: string }>;
  70. configs: Record<string, { isServerConfigured?: boolean }>;
  71. selectedId: T;
  72. onSelect: (id: T) => void;
  73. width: number;
  74. t: (key: string) => string;
  75. }) {
  76. return (
  77. <div className="flex-shrink-0 bg-background flex flex-col" style={{ width }}>
  78. <div className="flex-1 overflow-y-auto p-3 space-y-1.5">
  79. {providers.map((provider) => (
  80. <button
  81. key={provider.id}
  82. onClick={() => onSelect(provider.id)}
  83. className={cn(
  84. 'w-full flex items-center gap-2.5 px-3 py-2.5 rounded-lg transition-all border text-left',
  85. selectedId === provider.id
  86. ? 'bg-primary/5 border-primary/50 shadow-sm'
  87. : 'border-transparent hover:bg-muted/50',
  88. )}
  89. >
  90. {provider.icon ? (
  91. <img
  92. src={provider.icon}
  93. alt={provider.name}
  94. className={cn(
  95. 'w-5 h-5 rounded',
  96. MONO_LOGO_PROVIDERS.has(provider.id) && 'dark:invert',
  97. )}
  98. onError={(e) => {
  99. (e.target as HTMLImageElement).style.display = 'none';
  100. }}
  101. />
  102. ) : (
  103. <Box className="h-5 w-5 text-muted-foreground" />
  104. )}
  105. <span className="font-medium text-sm flex-1 truncate">{provider.name}</span>
  106. {configs[provider.id]?.isServerConfigured && (
  107. <span className="text-[10px] px-1 py-0 h-4 leading-4 rounded shrink-0 bg-muted text-muted-foreground">
  108. {t('settings.serverConfigured')}
  109. </span>
  110. )}
  111. </button>
  112. ))}
  113. </div>
  114. </div>
  115. );
  116. }
  117. // ─── Helper: get TTS/ASR provider display name ───
  118. function getTTSProviderName(providerId: TTSProviderId, t: (key: string) => string): string {
  119. const names: Record<TTSProviderId, string> = {
  120. 'openai-tts': t('settings.providerOpenAITTS'),
  121. 'azure-tts': t('settings.providerAzureTTS'),
  122. 'glm-tts': t('settings.providerGLMTTS'),
  123. 'qwen-tts': t('settings.providerQwenTTS'),
  124. 'doubao-tts': t('settings.providerDoubaoTTS'),
  125. 'elevenlabs-tts': t('settings.providerElevenLabsTTS'),
  126. 'minimax-tts': t('settings.providerMiniMaxTTS'),
  127. 'browser-native-tts': t('settings.providerBrowserNativeTTS'),
  128. };
  129. return names[providerId];
  130. }
  131. function getASRProviderName(providerId: ASRProviderId, t: (key: string) => string): string {
  132. const names: Record<ASRProviderId, string> = {
  133. 'openai-whisper': t('settings.providerOpenAIWhisper'),
  134. 'browser-native': t('settings.providerBrowserNative'),
  135. 'qwen-asr': t('settings.providerQwenASR'),
  136. };
  137. return names[providerId];
  138. }
  139. // ─── Image/Video provider name helpers ───
  140. const IMAGE_PROVIDER_NAMES: Record<ImageProviderId, string> = {
  141. seedream: 'providerSeedream',
  142. 'qwen-image': 'providerQwenImage',
  143. 'nano-banana': 'providerNanoBanana',
  144. 'minimax-image': 'providerMiniMaxImage',
  145. 'grok-image': 'providerGrokImage',
  146. };
  147. const IMAGE_PROVIDER_ICONS: Record<ImageProviderId, string> = {
  148. seedream: '/logos/doubao.svg',
  149. 'qwen-image': '/logos/bailian.svg',
  150. 'nano-banana': '/logos/gemini.svg',
  151. 'minimax-image': '/logos/minimax.svg',
  152. 'grok-image': '/logos/grok.svg',
  153. };
  154. const VIDEO_PROVIDER_NAMES: Record<VideoProviderId, string> = {
  155. seedance: 'providerSeedance',
  156. kling: 'providerKling',
  157. veo: 'providerVeo',
  158. sora: 'providerSora',
  159. 'minimax-video': 'providerMiniMaxVideo',
  160. 'grok-video': 'providerGrokVideo',
  161. };
  162. const VIDEO_PROVIDER_ICONS: Record<VideoProviderId, string> = {
  163. seedance: '/logos/doubao.svg',
  164. kling: '/logos/kling.svg',
  165. veo: '/logos/gemini.svg',
  166. sora: '/logos/openai.svg',
  167. 'minimax-video': '/logos/minimax.svg',
  168. 'grok-video': '/logos/grok.svg',
  169. };
  170. interface SettingsDialogProps {
  171. open: boolean;
  172. onOpenChange: (open: boolean) => void;
  173. initialSection?: SettingsSection;
  174. }
  175. export function SettingsDialog({ open, onOpenChange, initialSection }: SettingsDialogProps) {
  176. const { t } = useI18n();
  177. // Get settings from store
  178. const providerId = useSettingsStore((state) => state.providerId);
  179. const _modelId = useSettingsStore((state) => state.modelId);
  180. const providersConfig = useSettingsStore((state) => state.providersConfig);
  181. const pdfProviderId = useSettingsStore((state) => state.pdfProviderId);
  182. const pdfProvidersConfig = useSettingsStore((state) => state.pdfProvidersConfig);
  183. const webSearchProviderId = useSettingsStore((state) => state.webSearchProviderId);
  184. const webSearchProvidersConfig = useSettingsStore((state) => state.webSearchProvidersConfig);
  185. const imageProviderId = useSettingsStore((state) => state.imageProviderId);
  186. const imageProvidersConfig = useSettingsStore((state) => state.imageProvidersConfig);
  187. const videoProviderId = useSettingsStore((state) => state.videoProviderId);
  188. const videoProvidersConfig = useSettingsStore((state) => state.videoProvidersConfig);
  189. const ttsProviderId = useSettingsStore((state) => state.ttsProviderId);
  190. const ttsProvidersConfig = useSettingsStore((state) => state.ttsProvidersConfig);
  191. const asrProviderId = useSettingsStore((state) => state.asrProviderId);
  192. const asrProvidersConfig = useSettingsStore((state) => state.asrProvidersConfig);
  193. // Store actions
  194. const setModel = useSettingsStore((state) => state.setModel);
  195. const setProviderConfig = useSettingsStore((state) => state.setProviderConfig);
  196. const setProvidersConfig = useSettingsStore((state) => state.setProvidersConfig);
  197. const setTTSProvider = useSettingsStore((state) => state.setTTSProvider);
  198. const setASRProvider = useSettingsStore((state) => state.setASRProvider);
  199. // Navigation
  200. const [activeSection, setActiveSection] = useState<SettingsSection>('providers');
  201. const [selectedProviderId, setSelectedProviderId] = useState<ProviderId>(providerId);
  202. const [selectedPdfProviderId, setSelectedPdfProviderId] = useState<PDFProviderId>(pdfProviderId);
  203. const [selectedWebSearchProviderId, setSelectedWebSearchProviderId] =
  204. useState<WebSearchProviderId>(webSearchProviderId);
  205. const [selectedImageProviderId, setSelectedImageProviderId] =
  206. useState<ImageProviderId>(imageProviderId);
  207. const [selectedVideoProviderId, setSelectedVideoProviderId] =
  208. useState<VideoProviderId>(videoProviderId);
  209. // Navigate to initialSection when dialog opens
  210. useEffect(() => {
  211. if (open && initialSection) {
  212. // eslint-disable-next-line react-hooks/set-state-in-effect -- Sync section from prop when dialog opens
  213. setActiveSection(initialSection);
  214. }
  215. }, [open, initialSection]);
  216. // Model editing state
  217. const [editingModel, setEditingModel] = useState<EditingModel | null>(null);
  218. const [showModelDialog, setShowModelDialog] = useState(false);
  219. // Provider deletion confirmation
  220. const [providerToDelete, setProviderToDelete] = useState<ProviderId | null>(null);
  221. // Add provider dialog
  222. const [showAddProviderDialog, setShowAddProviderDialog] = useState(false);
  223. // Save status indicator
  224. const [saveStatus, setSaveStatus] = useState<'idle' | 'saved' | 'error'>('idle');
  225. // Resizable column widths
  226. const [sidebarWidth, setSidebarWidth] = useState(192);
  227. const [providerListWidth, setProviderListWidth] = useState(192);
  228. const [isResizing, setIsResizing] = useState(false);
  229. const resizeRef = useRef<{
  230. target: 'sidebar' | 'providerList';
  231. startX: number;
  232. startWidth: number;
  233. } | null>(null);
  234. const handleResizeStart = useCallback(
  235. (e: React.MouseEvent, target: 'sidebar' | 'providerList') => {
  236. e.preventDefault();
  237. const startWidth = target === 'sidebar' ? sidebarWidth : providerListWidth;
  238. resizeRef.current = { target, startX: e.clientX, startWidth };
  239. setIsResizing(true);
  240. },
  241. [sidebarWidth, providerListWidth],
  242. );
  243. useEffect(() => {
  244. if (!isResizing) return;
  245. const handleMouseMove = (e: MouseEvent) => {
  246. if (!resizeRef.current) return;
  247. const { target, startX, startWidth } = resizeRef.current;
  248. const delta = e.clientX - startX;
  249. const newWidth = Math.max(120, Math.min(360, startWidth + delta));
  250. if (target === 'sidebar') {
  251. setSidebarWidth(newWidth);
  252. } else {
  253. setProviderListWidth(newWidth);
  254. }
  255. };
  256. const handleMouseUp = () => {
  257. resizeRef.current = null;
  258. setIsResizing(false);
  259. };
  260. document.addEventListener('mousemove', handleMouseMove);
  261. document.addEventListener('mouseup', handleMouseUp);
  262. document.body.style.userSelect = 'none';
  263. document.body.style.cursor = 'col-resize';
  264. return () => {
  265. document.removeEventListener('mousemove', handleMouseMove);
  266. document.removeEventListener('mouseup', handleMouseUp);
  267. document.body.style.userSelect = '';
  268. document.body.style.cursor = '';
  269. };
  270. }, [isResizing]);
  271. const handleSave = () => {
  272. onOpenChange(false);
  273. };
  274. const handleProviderSelect = (pid: ProviderId) => {
  275. setSelectedProviderId(pid);
  276. };
  277. const handleProviderConfigChange = (
  278. pid: ProviderId,
  279. apiKey: string,
  280. baseUrl: string,
  281. requiresApiKey: boolean,
  282. ) => {
  283. setProviderConfig(pid, {
  284. apiKey,
  285. baseUrl,
  286. requiresApiKey,
  287. });
  288. };
  289. const handleProviderConfigSave = () => {
  290. setSaveStatus('saved');
  291. setTimeout(() => setSaveStatus('idle'), 2000);
  292. };
  293. const selectedProvider = providersConfig[selectedProviderId]
  294. ? {
  295. id: selectedProviderId,
  296. name: providersConfig[selectedProviderId].name,
  297. type: providersConfig[selectedProviderId].type,
  298. defaultBaseUrl: providersConfig[selectedProviderId].defaultBaseUrl,
  299. icon: providersConfig[selectedProviderId].icon,
  300. requiresApiKey: providersConfig[selectedProviderId].requiresApiKey,
  301. models: providersConfig[selectedProviderId].models,
  302. }
  303. : undefined;
  304. // Handle model editing
  305. const handleEditModel = (pid: ProviderId, modelIndex: number) => {
  306. const allModels = providersConfig[pid]?.models || [];
  307. setEditingModel({
  308. providerId: pid,
  309. modelIndex,
  310. model: { ...allModels[modelIndex] },
  311. });
  312. setShowModelDialog(true);
  313. };
  314. const handleAddModel = () => {
  315. setEditingModel({
  316. providerId: selectedProviderId,
  317. modelIndex: null,
  318. model: {
  319. id: '',
  320. name: '',
  321. capabilities: {
  322. streaming: true,
  323. tools: true,
  324. vision: false,
  325. },
  326. },
  327. });
  328. setShowModelDialog(true);
  329. };
  330. const handleDeleteModel = (pid: ProviderId, modelIndex: number) => {
  331. const currentModels = providersConfig[pid]?.models || [];
  332. const newModels = currentModels.filter((_, i) => i !== modelIndex);
  333. setProviderConfig(pid, { models: newModels });
  334. };
  335. const handleAutoSaveModel = () => {
  336. if (!editingModel) return;
  337. const { providerId: pid, modelIndex, model } = editingModel;
  338. if (!model.id.trim()) return;
  339. const currentModels = providersConfig[pid]?.models || [];
  340. let newModels: typeof currentModels;
  341. let newModelIndex = modelIndex;
  342. if (modelIndex === null) {
  343. const existingIndex = currentModels.findIndex((m) => m.id === model.id);
  344. if (existingIndex >= 0) {
  345. newModels = [...currentModels];
  346. newModels[existingIndex] = model;
  347. newModelIndex = existingIndex;
  348. } else {
  349. newModels = [...currentModels, model];
  350. newModelIndex = newModels.length - 1;
  351. }
  352. setProviderConfig(pid, { models: newModels });
  353. setEditingModel({ ...editingModel, modelIndex: newModelIndex });
  354. } else {
  355. newModels = [...currentModels];
  356. newModels[modelIndex] = model;
  357. setProviderConfig(pid, { models: newModels });
  358. }
  359. };
  360. const handleSaveModel = () => {
  361. if (!editingModel) return;
  362. const { providerId: pid, modelIndex, model } = editingModel;
  363. if (!model.id.trim()) {
  364. toast.error(t('settings.modelIdRequired'));
  365. return;
  366. }
  367. const currentModels = providersConfig[pid]?.models || [];
  368. let newModels: typeof currentModels;
  369. if (modelIndex === null) {
  370. newModels = [...currentModels, model];
  371. } else {
  372. newModels = [...currentModels];
  373. newModels[modelIndex] = model;
  374. }
  375. setProviderConfig(pid, { models: newModels });
  376. setShowModelDialog(false);
  377. setEditingModel(null);
  378. };
  379. // Handle provider management
  380. const handleAddProvider = (providerData: NewProviderData) => {
  381. if (!providerData.name.trim()) {
  382. toast.error(t('settings.providerNameRequired'));
  383. return;
  384. }
  385. const newProviderId = `custom-${Date.now()}` as ProviderId;
  386. const updatedConfig = {
  387. ...providersConfig,
  388. [newProviderId]: {
  389. apiKey: '',
  390. baseUrl: '',
  391. models: [],
  392. name: providerData.name,
  393. type: providerData.type,
  394. defaultBaseUrl: providerData.baseUrl || undefined,
  395. icon: providerData.icon || undefined,
  396. requiresApiKey: providerData.requiresApiKey,
  397. isBuiltIn: false,
  398. },
  399. };
  400. setProvidersConfig(updatedConfig);
  401. setShowAddProviderDialog(false);
  402. setSelectedProviderId(newProviderId);
  403. };
  404. const handleDeleteProvider = (pid: ProviderId) => {
  405. if (providersConfig[pid]?.isBuiltIn) {
  406. toast.error(t('settings.cannotDeleteBuiltIn'));
  407. return;
  408. }
  409. setProviderToDelete(pid);
  410. };
  411. const confirmDeleteProvider = () => {
  412. if (!providerToDelete) return;
  413. const pid = providerToDelete;
  414. const updatedConfig = { ...providersConfig };
  415. delete updatedConfig[pid];
  416. setProvidersConfig(updatedConfig);
  417. if (selectedProviderId === pid) {
  418. const firstRemainingPid = Object.keys(updatedConfig)[0] as ProviderId | undefined;
  419. setSelectedProviderId(firstRemainingPid || 'openai');
  420. }
  421. if (providerId === pid) {
  422. const firstRemainingPid = Object.keys(updatedConfig)[0] as ProviderId | undefined;
  423. const firstModel = firstRemainingPid
  424. ? updatedConfig[firstRemainingPid]?.serverModels?.[0] ||
  425. updatedConfig[firstRemainingPid]?.models?.[0]?.id
  426. : undefined;
  427. if (firstRemainingPid && firstModel) {
  428. setModel(firstRemainingPid, firstModel);
  429. } else {
  430. setModel('openai' as ProviderId, 'gpt-4o-mini');
  431. }
  432. }
  433. setProviderToDelete(null);
  434. };
  435. const handleResetProvider = (pid: ProviderId) => {
  436. const provider = PROVIDERS[pid];
  437. if (!provider) return;
  438. setProviderConfig(pid, { models: [...provider.models] });
  439. toast.success(t('settings.resetSuccess'));
  440. };
  441. // Get all providers from providersConfig
  442. const allProviders = Object.entries(providersConfig).map(([id, config]) => ({
  443. id: id as ProviderId,
  444. name: config.name,
  445. type: config.type,
  446. defaultBaseUrl: config.defaultBaseUrl,
  447. icon: config.icon,
  448. requiresApiKey: config.requiresApiKey,
  449. models: config.models,
  450. isServerConfigured: config.isServerConfigured,
  451. }));
  452. // Sections that show a provider list column
  453. const _hasProviderList = [
  454. 'providers',
  455. 'pdf',
  456. 'web-search',
  457. 'image',
  458. 'video',
  459. 'tts',
  460. 'asr',
  461. ].includes(activeSection);
  462. // Get header content based on section
  463. const getHeaderContent = () => {
  464. switch (activeSection) {
  465. case 'general':
  466. return <h2 className="text-lg font-semibold">{t('settings.systemSettings')}</h2>;
  467. case 'providers':
  468. if (selectedProvider) {
  469. return (
  470. <>
  471. {selectedProvider.icon ? (
  472. <img
  473. src={selectedProvider.icon}
  474. alt={selectedProvider.name}
  475. className={cn(
  476. 'w-8 h-8 rounded',
  477. MONO_LOGO_PROVIDERS.has(selectedProvider.id) && 'dark:invert',
  478. )}
  479. onError={(e) => {
  480. (e.target as HTMLImageElement).style.display = 'none';
  481. }}
  482. />
  483. ) : (
  484. <Box className="h-8 w-8 text-muted-foreground" />
  485. )}
  486. <div>
  487. <h2 className="text-lg font-semibold">
  488. {t(`settings.providerNames.${selectedProvider.id}`) !==
  489. `settings.providerNames.${selectedProvider.id}`
  490. ? t(`settings.providerNames.${selectedProvider.id}`)
  491. : selectedProvider.name}
  492. </h2>
  493. <p className="text-xs text-muted-foreground">
  494. {getProviderTypeLabel(selectedProvider.type, t)}
  495. </p>
  496. </div>
  497. </>
  498. );
  499. }
  500. return null;
  501. case 'pdf': {
  502. const pdfProvider = PDF_PROVIDERS[selectedPdfProviderId];
  503. if (!pdfProvider) return null;
  504. return (
  505. <>
  506. {pdfProvider.icon ? (
  507. <img
  508. src={pdfProvider.icon}
  509. alt={pdfProvider.name}
  510. className="w-8 h-8 rounded"
  511. onError={(e) => {
  512. (e.target as HTMLImageElement).style.display = 'none';
  513. }}
  514. />
  515. ) : (
  516. <Box className="h-8 w-8 text-muted-foreground" />
  517. )}
  518. <h2 className="text-lg font-semibold">{pdfProvider.name}</h2>
  519. </>
  520. );
  521. }
  522. case 'web-search': {
  523. const wsProvider = WEB_SEARCH_PROVIDERS[selectedWebSearchProviderId];
  524. if (!wsProvider) return null;
  525. return (
  526. <>
  527. {wsProvider.icon ? (
  528. <img
  529. src={wsProvider.icon}
  530. alt={wsProvider.name}
  531. className="w-8 h-8 rounded"
  532. onError={(e) => {
  533. (e.target as HTMLImageElement).style.display = 'none';
  534. }}
  535. />
  536. ) : (
  537. <Box className="h-8 w-8 text-muted-foreground" />
  538. )}
  539. <h2 className="text-lg font-semibold">{wsProvider.name}</h2>
  540. </>
  541. );
  542. }
  543. case 'image': {
  544. const imgProvider = IMAGE_PROVIDERS[selectedImageProviderId];
  545. const imgIcon = IMAGE_PROVIDER_ICONS[selectedImageProviderId];
  546. return (
  547. <>
  548. {imgIcon ? (
  549. <img
  550. src={imgIcon}
  551. alt={imgProvider?.name}
  552. className="w-8 h-8 rounded"
  553. onError={(e) => {
  554. (e.target as HTMLImageElement).style.display = 'none';
  555. }}
  556. />
  557. ) : (
  558. <Box className="h-8 w-8 text-muted-foreground" />
  559. )}
  560. <h2 className="text-lg font-semibold">
  561. {t(`settings.${IMAGE_PROVIDER_NAMES[selectedImageProviderId]}`) || imgProvider?.name}
  562. </h2>
  563. </>
  564. );
  565. }
  566. case 'video': {
  567. const vidProvider = VIDEO_PROVIDERS[selectedVideoProviderId];
  568. const vidIcon = VIDEO_PROVIDER_ICONS[selectedVideoProviderId];
  569. return (
  570. <>
  571. {vidIcon ? (
  572. <img
  573. src={vidIcon}
  574. alt={vidProvider?.name}
  575. className="w-8 h-8 rounded"
  576. onError={(e) => {
  577. (e.target as HTMLImageElement).style.display = 'none';
  578. }}
  579. />
  580. ) : (
  581. <Box className="h-8 w-8 text-muted-foreground" />
  582. )}
  583. <h2 className="text-lg font-semibold">
  584. {t(`settings.${VIDEO_PROVIDER_NAMES[selectedVideoProviderId]}`) || vidProvider?.name}
  585. </h2>
  586. </>
  587. );
  588. }
  589. case 'tts': {
  590. const ttsIcon = TTS_PROVIDERS[ttsProviderId]?.icon;
  591. return (
  592. <>
  593. {ttsIcon ? (
  594. <img
  595. src={ttsIcon}
  596. alt=""
  597. className="w-8 h-8 rounded"
  598. onError={(e) => {
  599. (e.target as HTMLImageElement).style.display = 'none';
  600. }}
  601. />
  602. ) : (
  603. <Volume2 className="h-6 w-6 text-muted-foreground" />
  604. )}
  605. <h2 className="text-lg font-semibold">{getTTSProviderName(ttsProviderId, t)}</h2>
  606. </>
  607. );
  608. }
  609. case 'asr': {
  610. const asrIcon = ASR_PROVIDERS[asrProviderId]?.icon;
  611. return (
  612. <>
  613. {asrIcon ? (
  614. <img
  615. src={asrIcon}
  616. alt=""
  617. className="w-8 h-8 rounded"
  618. onError={(e) => {
  619. (e.target as HTMLImageElement).style.display = 'none';
  620. }}
  621. />
  622. ) : (
  623. <Mic className="h-6 w-6 text-muted-foreground" />
  624. )}
  625. <h2 className="text-lg font-semibold">{getASRProviderName(asrProviderId, t)}</h2>
  626. </>
  627. );
  628. }
  629. default:
  630. return null;
  631. }
  632. };
  633. return (
  634. <Dialog open={open} onOpenChange={onOpenChange}>
  635. <DialogContent className="h-[85vh] p-0 gap-0 block" showCloseButton={false}>
  636. <DialogTitle className="sr-only">{t('settings.title')}</DialogTitle>
  637. <DialogDescription className="sr-only">{t('settings.description')}</DialogDescription>
  638. <div className="flex h-full overflow-hidden">
  639. {/* Left Sidebar - Navigation */}
  640. <div className="flex-shrink-0 bg-muted/30 p-3 space-y-1" style={{ width: sidebarWidth }}>
  641. <button
  642. onClick={() => setActiveSection('providers')}
  643. className={cn(
  644. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  645. activeSection === 'providers'
  646. ? 'bg-primary/10 text-primary font-medium'
  647. : 'hover:bg-muted',
  648. )}
  649. >
  650. <Box className="h-4 w-4 shrink-0" />
  651. <span className="truncate">{t('settings.providers')}</span>
  652. </button>
  653. <button
  654. onClick={() => setActiveSection('image')}
  655. className={cn(
  656. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  657. activeSection === 'image'
  658. ? 'bg-primary/10 text-primary font-medium'
  659. : 'hover:bg-muted',
  660. )}
  661. >
  662. <ImageIcon className="h-4 w-4 shrink-0" />
  663. <span className="truncate">{t('settings.imageSettings')}</span>
  664. </button>
  665. <button
  666. onClick={() => setActiveSection('video')}
  667. className={cn(
  668. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  669. activeSection === 'video'
  670. ? 'bg-primary/10 text-primary font-medium'
  671. : 'hover:bg-muted',
  672. )}
  673. >
  674. <Film className="h-4 w-4 shrink-0" />
  675. <span className="truncate">{t('settings.videoSettings')}</span>
  676. </button>
  677. <button
  678. onClick={() => setActiveSection('tts')}
  679. className={cn(
  680. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  681. activeSection === 'tts'
  682. ? 'bg-primary/10 text-primary font-medium'
  683. : 'hover:bg-muted',
  684. )}
  685. >
  686. <Volume2 className="h-4 w-4 shrink-0" />
  687. <span className="truncate">{t('settings.ttsSettings')}</span>
  688. </button>
  689. <button
  690. onClick={() => setActiveSection('asr')}
  691. className={cn(
  692. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  693. activeSection === 'asr'
  694. ? 'bg-primary/10 text-primary font-medium'
  695. : 'hover:bg-muted',
  696. )}
  697. >
  698. <Mic className="h-4 w-4 shrink-0" />
  699. <span className="truncate">{t('settings.asrSettings')}</span>
  700. </button>
  701. <button
  702. onClick={() => setActiveSection('pdf')}
  703. className={cn(
  704. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  705. activeSection === 'pdf'
  706. ? 'bg-primary/10 text-primary font-medium'
  707. : 'hover:bg-muted',
  708. )}
  709. >
  710. <FileText className="h-4 w-4 shrink-0" />
  711. <span className="truncate">{t('settings.pdfSettings')}</span>
  712. </button>
  713. <button
  714. onClick={() => setActiveSection('web-search')}
  715. className={cn(
  716. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  717. activeSection === 'web-search'
  718. ? 'bg-primary/10 text-primary font-medium'
  719. : 'hover:bg-muted',
  720. )}
  721. >
  722. <Search className="h-4 w-4 shrink-0" />
  723. <span className="truncate">{t('settings.webSearchSettings')}</span>
  724. </button>
  725. <button
  726. onClick={() => setActiveSection('general')}
  727. className={cn(
  728. 'w-full flex items-center gap-3 px-3 py-2 text-sm rounded-lg transition-colors text-left min-w-0',
  729. activeSection === 'general'
  730. ? 'bg-primary/10 text-primary font-medium'
  731. : 'hover:bg-muted',
  732. )}
  733. >
  734. <Settings className="h-4 w-4 shrink-0" />
  735. <span className="truncate">{t('settings.systemSettings')}</span>
  736. </button>
  737. </div>
  738. {/* Sidebar resize handle */}
  739. <div
  740. onMouseDown={(e) => handleResizeStart(e, 'sidebar')}
  741. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  742. >
  743. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  744. </div>
  745. {/* Middle - Provider List (only shown for provider-based sections) */}
  746. {activeSection === 'providers' && (
  747. <>
  748. <ProviderList
  749. providers={allProviders}
  750. selectedProviderId={selectedProviderId}
  751. onSelect={handleProviderSelect}
  752. onAddProvider={() => setShowAddProviderDialog(true)}
  753. width={providerListWidth}
  754. />
  755. <div
  756. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  757. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  758. >
  759. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  760. </div>
  761. </>
  762. )}
  763. {activeSection === 'pdf' && (
  764. <>
  765. <ProviderListColumn
  766. providers={Object.values(PDF_PROVIDERS)}
  767. configs={pdfProvidersConfig}
  768. selectedId={selectedPdfProviderId}
  769. onSelect={setSelectedPdfProviderId}
  770. width={providerListWidth}
  771. t={t}
  772. />
  773. <div
  774. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  775. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  776. >
  777. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  778. </div>
  779. </>
  780. )}
  781. {activeSection === 'web-search' && (
  782. <>
  783. <ProviderListColumn
  784. providers={Object.values(WEB_SEARCH_PROVIDERS)}
  785. configs={webSearchProvidersConfig}
  786. selectedId={selectedWebSearchProviderId}
  787. onSelect={setSelectedWebSearchProviderId}
  788. width={providerListWidth}
  789. t={t}
  790. />
  791. <div
  792. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  793. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  794. >
  795. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  796. </div>
  797. </>
  798. )}
  799. {activeSection === 'image' && (
  800. <>
  801. <ProviderListColumn
  802. providers={Object.values(IMAGE_PROVIDERS).map((p) => ({
  803. id: p.id,
  804. name: t(`settings.${IMAGE_PROVIDER_NAMES[p.id]}`) || p.name,
  805. icon: IMAGE_PROVIDER_ICONS[p.id],
  806. }))}
  807. configs={imageProvidersConfig}
  808. selectedId={selectedImageProviderId}
  809. onSelect={setSelectedImageProviderId}
  810. width={providerListWidth}
  811. t={t}
  812. />
  813. <div
  814. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  815. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  816. >
  817. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  818. </div>
  819. </>
  820. )}
  821. {activeSection === 'video' && (
  822. <>
  823. <ProviderListColumn
  824. providers={Object.values(VIDEO_PROVIDERS).map((p) => ({
  825. id: p.id,
  826. name: t(`settings.${VIDEO_PROVIDER_NAMES[p.id]}`) || p.name,
  827. icon: VIDEO_PROVIDER_ICONS[p.id],
  828. }))}
  829. configs={videoProvidersConfig}
  830. selectedId={selectedVideoProviderId}
  831. onSelect={setSelectedVideoProviderId}
  832. width={providerListWidth}
  833. t={t}
  834. />
  835. <div
  836. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  837. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  838. >
  839. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  840. </div>
  841. </>
  842. )}
  843. {activeSection === 'tts' && (
  844. <>
  845. <ProviderListColumn
  846. providers={Object.values(TTS_PROVIDERS).map((p) => ({
  847. id: p.id,
  848. name: getTTSProviderName(p.id, t),
  849. icon: p.icon,
  850. }))}
  851. configs={ttsProvidersConfig}
  852. selectedId={ttsProviderId}
  853. onSelect={setTTSProvider}
  854. width={providerListWidth}
  855. t={t}
  856. />
  857. <div
  858. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  859. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  860. >
  861. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  862. </div>
  863. </>
  864. )}
  865. {activeSection === 'asr' && (
  866. <>
  867. <ProviderListColumn
  868. providers={Object.values(ASR_PROVIDERS).map((p) => ({
  869. id: p.id,
  870. name: getASRProviderName(p.id, t),
  871. icon: p.icon,
  872. }))}
  873. configs={asrProvidersConfig}
  874. selectedId={asrProviderId}
  875. onSelect={setASRProvider}
  876. width={providerListWidth}
  877. t={t}
  878. />
  879. <div
  880. onMouseDown={(e) => handleResizeStart(e, 'providerList')}
  881. className="flex-shrink-0 w-[5px] cursor-col-resize group flex justify-center"
  882. >
  883. <div className="w-px h-full bg-border group-hover:bg-primary/50 transition-colors" />
  884. </div>
  885. </>
  886. )}
  887. {/* Right - Configuration Panel */}
  888. <div className="flex-1 flex flex-col overflow-hidden min-w-0">
  889. {/* Header */}
  890. <div className="flex items-center justify-between p-5 border-b">
  891. <div className="flex items-center gap-3">{getHeaderContent()}</div>
  892. <div className="flex items-center gap-2">
  893. {activeSection === 'providers' &&
  894. !providersConfig[selectedProviderId]?.isBuiltIn && (
  895. <Button
  896. variant="ghost"
  897. size="sm"
  898. className="h-7 px-2 text-destructive hover:text-destructive"
  899. onClick={() => handleDeleteProvider(selectedProviderId)}
  900. >
  901. <Trash2 className="h-4 w-4" />
  902. </Button>
  903. )}
  904. <Button variant="ghost" size="icon" onClick={() => onOpenChange(false)}>
  905. <X className="h-4 w-4" />
  906. </Button>
  907. </div>
  908. </div>
  909. {/* Content */}
  910. <div className="flex-1 overflow-y-auto p-5">
  911. {activeSection === 'general' && <GeneralSettings />}
  912. {activeSection === 'providers' && selectedProvider && (
  913. <ProviderConfigPanel
  914. provider={selectedProvider}
  915. initialApiKey={providersConfig[selectedProviderId]?.apiKey || ''}
  916. initialBaseUrl={providersConfig[selectedProviderId]?.baseUrl || ''}
  917. initialRequiresApiKey={
  918. providersConfig[selectedProviderId]?.requiresApiKey ?? true
  919. }
  920. providersConfig={providersConfig}
  921. onConfigChange={(apiKey, baseUrl, requiresApiKey) =>
  922. handleProviderConfigChange(selectedProviderId, apiKey, baseUrl, requiresApiKey)
  923. }
  924. onSave={handleProviderConfigSave}
  925. onEditModel={(index) => handleEditModel(selectedProviderId, index)}
  926. onDeleteModel={(index) => handleDeleteModel(selectedProviderId, index)}
  927. onAddModel={handleAddModel}
  928. onResetToDefault={() => handleResetProvider(selectedProviderId)}
  929. isBuiltIn={providersConfig[selectedProviderId]?.isBuiltIn ?? true}
  930. />
  931. )}
  932. {activeSection === 'pdf' && (
  933. <PDFSettings selectedProviderId={selectedPdfProviderId} />
  934. )}
  935. {activeSection === 'web-search' && (
  936. <WebSearchSettings selectedProviderId={selectedWebSearchProviderId} />
  937. )}
  938. {activeSection === 'image' && (
  939. <ImageSettings selectedProviderId={selectedImageProviderId} />
  940. )}
  941. {activeSection === 'video' && (
  942. <VideoSettings selectedProviderId={selectedVideoProviderId} />
  943. )}
  944. {activeSection === 'tts' && <TTSSettings selectedProviderId={ttsProviderId} />}
  945. {activeSection === 'asr' && <ASRSettings selectedProviderId={asrProviderId} />}
  946. </div>
  947. {/* Footer */}
  948. <div className="flex items-center justify-end gap-3 px-5 py-3 border-t bg-muted/30">
  949. {saveStatus === 'saved' && (
  950. <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
  951. <CheckCircle2 className="h-4 w-4" />
  952. <span>{t('settings.saveSuccess')}</span>
  953. </div>
  954. )}
  955. {saveStatus === 'error' && (
  956. <div className="flex items-center gap-1.5 text-sm text-muted-foreground">
  957. <XCircle className="h-4 w-4" />
  958. <span>{t('settings.saveFailed')}</span>
  959. </div>
  960. )}
  961. <Button variant="outline" size="sm" onClick={() => onOpenChange(false)}>
  962. {t('settings.close')}
  963. </Button>
  964. <Button size="sm" onClick={handleSave}>
  965. {t('settings.save')}
  966. </Button>
  967. </div>
  968. </div>
  969. </div>
  970. </DialogContent>
  971. {/* Edit Model Dialog */}
  972. <ModelEditDialog
  973. open={showModelDialog}
  974. onOpenChange={setShowModelDialog}
  975. editingModel={editingModel}
  976. setEditingModel={setEditingModel}
  977. onSave={handleSaveModel}
  978. onAutoSave={handleAutoSaveModel}
  979. providerId={selectedProviderId}
  980. apiKey={providersConfig[selectedProviderId]?.apiKey || ''}
  981. baseUrl={providersConfig[selectedProviderId]?.baseUrl}
  982. providerType={providersConfig[selectedProviderId]?.type}
  983. requiresApiKey={providersConfig[selectedProviderId]?.requiresApiKey}
  984. isServerConfigured={providersConfig[selectedProviderId]?.isServerConfigured}
  985. />
  986. {/* Add Provider Dialog */}
  987. <AddProviderDialog
  988. open={showAddProviderDialog}
  989. onOpenChange={setShowAddProviderDialog}
  990. onAdd={handleAddProvider}
  991. />
  992. {/* Delete Provider Confirmation */}
  993. <AlertDialog
  994. open={providerToDelete !== null}
  995. onOpenChange={(open) => !open && setProviderToDelete(null)}
  996. >
  997. <AlertDialogContent>
  998. <AlertDialogHeader>
  999. <AlertDialogTitle>{t('settings.deleteProvider')}</AlertDialogTitle>
  1000. <AlertDialogDescription>{t('settings.deleteProviderConfirm')}</AlertDialogDescription>
  1001. </AlertDialogHeader>
  1002. <AlertDialogFooter>
  1003. <AlertDialogCancel>{t('settings.cancelEdit')}</AlertDialogCancel>
  1004. <AlertDialogAction onClick={confirmDeleteProvider}>
  1005. {t('settings.deleteProvider')}
  1006. </AlertDialogAction>
  1007. </AlertDialogFooter>
  1008. </AlertDialogContent>
  1009. </AlertDialog>
  1010. </Dialog>
  1011. );
  1012. }