// src/renderer/components/SettingsModal.tsx // 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭 import { useState, useEffect, useCallback, useMemo } from 'react' import { Dialog, DialogTitle, DialogContent, Tabs, Tab, Box, TextField, Select, MenuItem, Switch, FormControlLabel, Typography, IconButton, Divider, InputLabel, FormControl, Button, Paper, } from '@mui/material' import { X, Keyboard, Mic, Lock, Cloud, RefreshCw, Play, Cpu, HelpCircle, TextCursorInput } from 'lucide-react' import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme' import { Led } from '@d3ro/ui/components/ds' import { LicenseTab } from './LicenseTab' import { CloudSyncSection } from './CloudSyncSection' import { STTTab } from './STTTab' import { CaptionModelSelect } from './CaptionModelSelect' import { InputConsentPanel } from './input-insights/InputConsentPanel' import { KeyBindingField } from './keybinding/KeyBindingField' import { asTranslationKey } from './keybinding/translation-key' import { useI18n, LOCALE_META } from '@d3ro/i18n' import type { Locale } from '@d3ro/i18n' import type { KeyBindingActionGroup } from '@d3ro/core/keybinding' import { auditKeyBindingMap, KEYBINDING_ACTIONS } from '@d3ro/core/keybinding' import type { ThemeMode, AppConfig, AudioDevice, LLMModel, LLMStatus, STTModel } from '@d3ro/core/types' import { useKeyBindingMap } from '../hooks/useKeyBindingMap' interface SettingsModalProps { open: boolean initialTab?: number 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} } // ── 액션 그룹 섹션 ───────────────────────────────────── const ACTION_GROUP_LABEL_KEYS: Readonly> = { voice: 'keybinding.ui.sectionVoice', window: 'keybinding.ui.sectionWindow', input: 'keybinding.ui.sectionInput', } const ACTION_GROUP_ORDER: readonly KeyBindingActionGroup[] = ['voice', 'window', 'input'] export function SettingsModal({ open, initialTab = 0, onClose }: SettingsModalProps): React.ReactElement { const { t, locale, setLocale } = useI18n() const [activeTab, setActiveTab] = useState(initialTab) const [config, setConfig] = useState>({}) const [loading, setLoading] = useState(true) const [appVersion, setAppVersion] = useState(null) const keybindingMap = useKeyBindingMap() const keybindingIssues = useMemo( () => (keybindingMap === null ? [] : auditKeyBindingMap(keybindingMap)), [keybindingMap] ) useEffect(() => { if (open && initialTab !== undefined) { setActiveTab(initialTab) } }, [open, initialTab]) useEffect(() => { if (!open) return let cancelled = false void window.electronAPI.system.getVersion() .then((result) => { if (!cancelled && result.success) setAppVersion(result.data) }) .catch(() => { if (!cancelled) setAppVersion(null) }) return () => { cancelled = true } }, [open]) // 전역 단축키 on/off (개별 바인딩은 KeyBindingField 가 담당한다) const [keybindingEnabled, setKeybindingEnabled] = useState(true) // 오디오 디바이스 const [audioDevices, setAudioDevices] = useState([]) const [selectedDeviceId, setSelectedDeviceId] = useState('default') const [micTesting, setMicTesting] = useState(false) const [micLevel, setMicLevel] = useState(0) // 실시간 자막 모델 선택지 — 받아 둔 로컬 Whisper 모델만 const [downloadedSttModels, setDownloadedSttModels] = useState([]) useEffect(() => { if (!open) return window.electronAPI.stt.getModels().then((res) => { if (res.success && res.data) setDownloadedSttModels(res.data.filter((m) => m.downloaded)) }) }, [open]) // Ollama 상태 const [ollamaStatus, setOllamaStatus] = useState(null) const [ollamaModels, setOllamaModels] = useState([]) const [checkingOllama, setCheckingOllama] = useState(false) const [startingOllama, setStartingOllama] = useState(false) const [ollamaFeedback, setOllamaFeedback] = useState(null) const refreshOllama = useCallback(async () => { setCheckingOllama(true) try { const res = await window.electronAPI.llm.checkConnection() if (res.success && res.data) { setOllamaModels(res.data.models) } const st = await window.electronAPI.llm.getStatus() if (st.success) setOllamaStatus(st.data) } finally { setCheckingOllama(false) } }, []) // 설정 로드 useEffect(() => { if (!open) return setLoading(true) refreshOllama() Promise.all([ window.electronAPI.config.getAll(), window.electronAPI.keybinding.isEnabled(), ]) .then(([configResult, enabledResult]) => { if (configResult.success) setConfig(configResult.data) if (enabledResult.success) setKeybindingEnabled(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) }) }, [open, refreshOllama]) const updateConfig = useCallback((key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => { setConfig((prev) => ({ ...prev, [key]: value })) window.electronAPI.config.set({ key, value }) }, []) const handleKeybindingToggle = useCallback((enabled: boolean) => { setKeybindingEnabled(enabled) void window.electronAPI.keybinding.setEnabled({ enabled }) }, []) 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: d3roTypo.label.size, fontWeight: 500, letterSpacing: '0.5px', textTransform: 'uppercase', color: d3roPalette.text.inactive, minHeight: 40, py: 1, '&.Mui-selected': { color: d3roPalette.accent.main }, }, '& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.main, height: 2 }, }} > } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" /> } iconPosition="start" /> {/* ── 일반 탭 ─────────────────────────────── */} {t('settings.shortcuts')} handleKeybindingToggle(e.target.checked)} /> } label={ {t('keybinding.ui.globalEnabled')} } /> {keybindingIssues.length === 0 ? t('keybinding.ui.auditClear') : t('keybinding.ui.auditIssues', { count: keybindingIssues.length })} {keybindingIssues.slice(0, 3).map((issue) => { const action = KEYBINDING_ACTIONS.find((entry) => entry.id === issue.actionId) const actionLabel = action ? t(asTranslationKey(action.labelKey)) : issue.actionId const conflictLabels = issue.conflictActionIds .map((actionId) => { const conflict = KEYBINDING_ACTIONS.find((entry) => entry.id === actionId) return conflict ? t(asTranslationKey(conflict.labelKey)) : actionId }) .join(', ') return ( {issue.kind === 'invalid' ? t('keybinding.ui.auditInvalid', { action: actionLabel, reason: issue.reasonKey ? t(asTranslationKey(issue.reasonKey)) : t('keybinding.ui.auditUnknown') }) : t('keybinding.ui.auditConflict', { action: actionLabel, conflicts: conflictLabels })} ) })} {ACTION_GROUP_ORDER.map((group) => { const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group) if (actions.length === 0) return null return ( {t(asTranslationKey(ACTION_GROUP_LABEL_KEYS[group]))} {actions.map((action) => ( ))} ) })} {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.main, borderRadius: '4px', transition: 'width 100ms ease-out' }} /> {t('settings.captionAudio')} {t('settings.captionSource')} updateConfig('captionSttModelId', modelId)} /> updateConfig('captionRefineEnabled', e.target.checked)} /> } label={ {t('settings.captionRefine')} {t('settings.captionRefine.desc')} } /> {t('settings.textInsert')} {t('settings.insertMethod')} {/* ── STT 탭 ───────────────────────────────── */} {/* ── LLM 탭 ──────────────────────────────── */} {/* LLM 백엔드 선택 */} AI 처리 백엔드 선택 {/* 로컬 Ollama 설정 */} {config.llmBackend !== 'online' && ( {/* Ollama 연결 상태 카드 */} {ollamaStatus?.connectionState === 'connected' ? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}` : 'Ollama 오프라인 (서버 실행 필요)'} 설치된 모델: {ollamaModels.length}개 | 현재 활성: {config.llmModelId ?? 'gemma4:e4b'} {ollamaStatus?.connectionState !== 'connected' && ( )} {ollamaFeedback && ( {ollamaFeedback} )} {/* 서버 URL & 모델 선택 */} updateConfig('ollamaServerUrl', e.target.value)} fullWidth size="small" /> 활성 Ollama 모델 )} {/* 온라인 API 설정 */} {config.llmBackend === 'online' && ( updateConfig('onlineApiUrl', e.target.value)} fullWidth size="small" /> 계정 상태: {config.authToken ? `${config.userEmail ?? '로그인됨'} (온라인 API 가용)` : '로그인 필요'} )} {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')} } /> {/* ── 라이선스 탭 ────────────────────────────── */} {/* ── Cloud Sync 탭 ──────────────────────────── */} {/* ── 정보 탭 ──────────────────────────────── */} D3RO-VOICE {t('settings.about.version')} {appVersion === null ? '—' : `v${appVersion}`} {t('settings.about.techStack')} {t('settings.about.techStackValue')} {t('settings.about.voiceEngine')} {t('settings.about.voiceEngineValue')} {t('settings.about.description')} ) }