// 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 (
<>
>
)
}