// src/renderer/components/SettingsModal.tsx // 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭 import { useState, useEffect, useCallback } from 'react' import { Dialog, DialogTitle, DialogContent, Tabs, Tab, Box, TextField, Select, MenuItem, Switch, FormControlLabel, Typography, IconButton, Divider, InputLabel, FormControl, Button, Chip, Stack, Paper, } from '@mui/material' import CloseIcon from '@mui/icons-material/Close' import KeyboardIcon from '@mui/icons-material/Keyboard' import EditIcon from '@mui/icons-material/Edit' import MicIcon from '@mui/icons-material/Mic' import LockIcon from '@mui/icons-material/Lock' import CheckCircleIcon from '@mui/icons-material/CheckCircle' import CancelIcon from '@mui/icons-material/Cancel' import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme' import { HotkeyRecordModal } from './HotkeyRecordModal' import { LicenseTab } from './LicenseTab' import { useI18n, LOCALE_META } from '../i18n' import type { Locale } from '../i18n' import type { ThemeMode, AppConfig, HotkeyBinding, AudioDevice } from '@d3ro/core/types' import { Feature } from '@d3ro/core/types' interface SettingsModalProps { open: boolean onClose: () => void } interface TabPanelProps { children: React.ReactNode value: number index: number } function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement | null { if (value !== index) return null return {children} } // ── 핫키 표시 컴포넌트 ────────────────────────────────── function HotkeyDisplay({ binding, onEdit, label, notSetLabel, }: { binding: HotkeyBinding | null onEdit: () => void label: string notSetLabel: string }): React.ReactElement { return ( {label} {binding ? ( {binding.displayLabel.split(' + ').map((key) => ( ))} ) : ( {notSetLabel} )} ) } // ── 음성 모드 카드 ────────────────────────────────────── function VoiceModeCard({ title, description, enabled, onToggle, disabled, enabledLabel, disabledLabel, children, }: { title: string description: string enabled: boolean onToggle: (enabled: boolean) => void disabled?: boolean enabledLabel: string disabledLabel: string children?: React.ReactNode }): React.ReactElement { return ( {title} onToggle(e.target.checked)} disabled={disabled} size="small" sx={{ '& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green }, '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { backgroundColor: d3roPalette.tag.green, }, }} /> } label={ } sx={{ ml: 0, mr: 0 }} /> {description} {children && {children}} ) } export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement { const { t, locale, setLocale } = useI18n() const [activeTab, setActiveTab] = useState(0) const [config, setConfig] = useState>({}) const [loading, setLoading] = useState(true) // 음성 모드 상태 const [dictationEnabled, setDictationEnabled] = useState(true) const [dictationBinding, setDictationBinding] = useState(null) const [handsFreeEnabled, setHandsFreeEnabled] = useState(false) const [handsFreeBinding, setHandsFreeBinding] = useState(null) const [captionBinding, setCaptionBinding] = useState(null) const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true) // 핫키 녹화 모달 const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false) const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation') // LLM 모델 목록 const [llmModels, setLlmModels] = useState>([]) // 오디오 디바이스 const [audioDevices, setAudioDevices] = useState([]) const [selectedDeviceId, setSelectedDeviceId] = useState('default') const [micTesting, setMicTesting] = useState(false) const [micLevel, setMicLevel] = useState(0) // 설정 로드 useEffect(() => { if (!open) return setLoading(true) Promise.all([ window.electronAPI.config.getAll(), window.electronAPI.hotkey.getDictationShortcut(), window.electronAPI.hotkey.getHandsFreeShortcut(), window.electronAPI.hotkey.getCaptionShortcut(), window.electronAPI.hotkey.isEnabled(), ]) .then(([configResult, dictResult, hfResult, capResult, enabledResult]) => { if (configResult.success) setConfig(configResult.data) if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data) if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data) if (capResult.success && capResult.data) setCaptionBinding(capResult.data) if (enabledResult.success) { setHotkeyGlobalEnabled(enabledResult.data) setDictationEnabled(enabledResult.data) } }) .finally(() => setLoading(false)) Promise.all([ window.electronAPI.audio.getDevices(), window.electronAPI.audio.getSelectedDevice(), ]).then(([devicesResult, selectedResult]) => { if (devicesResult.success) setAudioDevices(devicesResult.data) if (selectedResult.success && selectedResult.data) setSelectedDeviceId(selectedResult.data) }) // LLM 모델 목록 로드 window.electronAPI.llm.getModels().then((resp) => { if (resp.success) setLlmModels(resp.data) }) }, [open]) const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => { setConfig((prev) => ({ ...prev, [key]: value })) window.electronAPI.config.set({ key, value }) }, []) const handleDictationToggle = useCallback( (enabled: boolean) => { setDictationEnabled(enabled) window.electronAPI.hotkey.setEnabled({ enabled }) if (!enabled && handsFreeEnabled) { setHandsFreeEnabled(false) } }, [handsFreeEnabled] ) const handleHandsFreeToggle = useCallback((enabled: boolean) => { if (enabled && !handsFreeBinding) { setHotkeyModalTarget('handsFree') setHotkeyModalOpen(true) return } setHandsFreeEnabled(enabled) }, [handsFreeBinding]) const handleHotkeySave = useCallback( (binding: HotkeyBinding) => { if (hotkeyModalTarget === 'dictation') { setDictationBinding(binding) window.electronAPI.hotkey.setDictationShortcut({ binding }) setDictationEnabled(true) window.electronAPI.hotkey.setEnabled({ enabled: true }) } else if (hotkeyModalTarget === 'handsFree') { setHandsFreeBinding(binding) window.electronAPI.hotkey.setHandsFreeShortcut({ binding }) setHandsFreeEnabled(true) } else { setCaptionBinding(binding) window.electronAPI.hotkey.setCaptionShortcut({ binding }) } }, [hotkeyModalTarget] ) const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree' | 'caption') => { setHotkeyModalTarget(target) setHotkeyModalOpen(true) }, []) const handleLanguageChange = useCallback((newLocale: string) => { setLocale(newLocale as Locale) setConfig((prev) => ({ ...prev, language: newLocale })) }, [setLocale]) if (loading) return return ( <> {t('settings.title')} setActiveTab(v)} variant="scrollable" scrollButtons={false} sx={{ bgcolor: d3roPalette.bg.sidebar, borderBottom: `1px solid ${d3roPalette.border.subtle}`, minHeight: 40, '& .MuiTab-root': { fontFamily: d3roFontMono, fontSize: '11px', fontWeight: 700, letterSpacing: '0.5px', textTransform: 'uppercase', color: d3roPalette.text.inactive, minHeight: 40, py: 1, '&.Mui-selected': { color: d3roPalette.accent.amber }, }, '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 }, }} > } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" /> {/* ── 일반 탭 ─────────────────────────────── */} {t('settings.shortcuts')} openHotkeyModal('dictation')} label={t('settings.key')} notSetLabel={t('settings.notSet')} /> openHotkeyModal('handsFree')} label={t('settings.key')} notSetLabel={t('settings.notSet')} /> openHotkeyModal('caption')} label={t('settings.key')} notSetLabel={t('settings.notSet')} /> {t('settings.interface')} {t('settings.theme')} {t('settings.language')} {t('settings.appBehavior')} updateConfig('closeToTray', e.target.checked)} />} label={t('settings.closeToTray')} /> updateConfig('autoLaunch', e.target.checked)} />} label={t('settings.autoLaunch')} /> updateConfig('autoInsert', e.target.checked)} />} label={t('settings.autoInsert')} /> updateConfig('soundEnabled', e.target.checked)} />} label={t('settings.soundEffects')} /> {/* ── 오디오 탭 ────────────────────────────── */} {t('settings.microphone')} {t('settings.inputDevice')} 0.7 ? d3roPalette.tag.red : d3roPalette.accent.amber, borderRadius: '4px', transition: 'width 100ms ease-out' }} /> {t('settings.captionAudio')} {t('settings.captionSource')} {t('settings.textInsert')} {t('settings.insertMethod')} {/* ── STT 탭 ───────────────────────────────── */} {t('settings.whisperModel')} {t('settings.sttLanguage')} {t('settings.diarization')} )['hfToken'] as string ?? ''} onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)} fullWidth size="small" helperText={t('settings.hfTokenHint')} /> )['diarizationEnabled'] as boolean ?? false} onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)} size="small" sx={{ '& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple }, '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { backgroundColor: d3roPalette.tag.purple, }, }} /> } label={ {t('settings.diarization')} {t('settings.diarizationHint')} } /> {/* ── LLM 탭 ──────────────────────────────── */} {t('settings.ollamaServer')} updateConfig('ollamaServerUrl', e.target.value)} fullWidth /> {t('settings.ollamaHint')} {t('settings.llmModel')} {t('settings.llmModel')} {t('settings.postProcess')} {t('settings.defaultAction')} {t('settings.actionHint')} {/* Phase 10: 음성 명령어 토글 */} {t('settings.voiceCommands')} )['voiceCommandsEnabled'] as boolean ?? false} onChange={async (_, checked) => { updateConfig('voiceCommandsEnabled' as keyof AppConfig, checked as never) await window.electronAPI.voiceCommand.setEnabled({ enabled: checked }) }} size="small" /> } label={ {t('settings.voiceCommands')} {t('settings.voiceCommands.desc')} } /> {/* Phase 10: 화면 컨텍스트 토글 */} {t('settings.screenContext')} { updateConfig('screenContextEnabled', checked) await window.electronAPI.context.setEnabled({ enabled: checked }) }} size="small" /> } label={ {t('settings.screenContext')} {t('settings.screenContext.desc')} } /> {/* ── 라이선스 탭 ────────────────────────────── */} {/* ── 정보 탭 ──────────────────────────────── */} D3RO-VOICE {t('settings.about.version')} v1.0.0 {t('settings.about.techStack')} {t('settings.about.techStackValue')} {t('settings.about.voiceEngine')} {t('settings.about.voiceEngineValue')} {t('settings.about.description')} setHotkeyModalOpen(false)} onSave={handleHotkeySave} currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding} title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')} /> ) }