d3ro-voice/apps/desktop/src/renderer/components/SettingsModal.tsx
Yun Chan ad6bb70c20 fix(settings): show the live caption model under the dictation model
The caption model selector now sits directly under the dictation model in
the STT tab's Local Whisper card, where people look for it, and stays in the
General tab's caption section as well; both use one shared component. The
first selector is renamed to 'Dictation model' so the two are told apart.
2026-09-24 22:08:05 +09:00

870 lines
38 KiB
TypeScript

// 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 <Box sx={{ pt: 2 }}>{children}</Box>
}
// ── 액션 그룹 섹션 ─────────────────────────────────────
const ACTION_GROUP_LABEL_KEYS: Readonly<Record<KeyBindingActionGroup, string>> = {
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<Partial<AppConfig>>({})
const [loading, setLoading] = useState(true)
const [appVersion, setAppVersion] = useState<string | null>(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<AudioDevice[]>([])
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('default')
const [micTesting, setMicTesting] = useState(false)
const [micLevel, setMicLevel] = useState(0)
// 실시간 자막 모델 선택지 — 받아 둔 로컬 Whisper 모델만
const [downloadedSttModels, setDownloadedSttModels] = useState<STTModel[]>([])
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<LLMStatus | null>(null)
const [ollamaModels, setOllamaModels] = useState<LLMModel[]>([])
const [checkingOllama, setCheckingOllama] = useState(false)
const [startingOllama, setStartingOllama] = useState(false)
const [ollamaFeedback, setOllamaFeedback] = useState<string | null>(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 <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
return (
<>
<Dialog
open={open}
onClose={onClose}
fullWidth
maxWidth="sm"
disableEnforceFocus
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: d3roShadow.chassis,
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
fontFamily: d3roFontMono,
fontWeight: 500,
fontSize: d3roTypo.body.size,
letterSpacing: d3roTypo.engrave.spacing,
textTransform: 'uppercase',
color: d3roPalette.accent.main,
py: 1.5,
}}
>
{t('settings.title')}
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
<X size={18} />
</IconButton>
</DialogTitle>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<DialogContent sx={{ bgcolor: d3roPalette.bg.app, p: 0 }}>
<Tabs
value={activeTab}
onChange={(_, v: number) => 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 },
}}
>
<Tab label={t('settings.tabs.general')} icon={<Keyboard size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.audio')} icon={<Mic size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.stt')} />
<Tab label={t('settings.tabs.llm')} />
<Tab label={t('input.tab')} icon={<TextCursorInput size={14} />} iconPosition="start" />
<Tab label={t('license.nav')} icon={<Lock size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.cloud') ?? 'Cloud'} icon={<Cloud size={14} />} iconPosition="start" />
<Tab label={t('settings.tabs.about')} />
</Tabs>
<Box sx={{ p: 3 }}>
{/* ── 일반 탭 ─────────────────────────────── */}
<TabPanel value={activeTab} index={0}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.shortcuts')}
</Typography>
<FormControlLabel
sx={{ ml: 'auto', mr: 0 }}
control={
<Switch
size="small"
checked={keybindingEnabled}
onChange={(e) => handleKeybindingToggle(e.target.checked)}
/>
}
label={
<Typography sx={{ fontSize: d3roTypo.label.size, color: d3roPalette.text.secondary }}>
{t('keybinding.ui.globalEnabled')}
</Typography>
}
/>
</Box>
<Box
sx={{
p: 1.25,
borderRadius: d3roRadius.inner,
border: `1px solid ${keybindingIssues.length === 0 ? d3roPalette.border.subtle : d3roPalette.status.warning}`,
bgcolor: d3roPalette.bg.inset,
display: 'flex',
flexDirection: 'column',
gap: 0.5
}}
aria-live="polite"
>
<Typography sx={{ fontSize: d3roTypo.label.size, color: d3roPalette.text.secondary }}>
{keybindingIssues.length === 0
? t('keybinding.ui.auditClear')
: t('keybinding.ui.auditIssues', { count: keybindingIssues.length })}
</Typography>
{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 (
<Typography key={`${issue.actionId}-${issue.bindingIndex}-${issue.kind}`} sx={{ fontSize: d3roTypo.small.size, color: d3roPalette.text.secondary }}>
{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 })}
</Typography>
)
})}
</Box>
{ACTION_GROUP_ORDER.map((group) => {
const actions = KEYBINDING_ACTIONS.filter((action) => action.group === group)
if (actions.length === 0) return null
return (
<Box key={group} sx={{ display: 'flex', flexDirection: 'column', gap: 1.25 }}>
<Typography
sx={{
color: d3roPalette.text.dimLabel,
fontSize: d3roTypo.engrave.size,
letterSpacing: '0.1em',
textTransform: 'uppercase',
}}
>
{t(asTranslationKey(ACTION_GROUP_LABEL_KEYS[group]))}
</Typography>
{actions.map((action) => (
<KeyBindingField key={action.id} actionId={action.id} />
))}
</Box>
)
})}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.interface')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.theme')}</InputLabel>
<Select
label={t('settings.theme')}
value={config.theme ?? 'auto'}
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
>
<MenuItem value="auto">{t('settings.theme.system')}</MenuItem>
<MenuItem value="light">{t('settings.theme.light')}</MenuItem>
<MenuItem value="dark">{t('settings.theme.dark')}</MenuItem>
<MenuItem value="nord">{t('settings.theme.nord')}</MenuItem>
<MenuItem value="solarized">{t('settings.theme.solarized')}</MenuItem>
<MenuItem value="catppuccin">{t('settings.theme.catppuccin')}</MenuItem>
<MenuItem value="dracula">{t('settings.theme.dracula')}</MenuItem>
</Select>
</FormControl>
<FormControl size="small">
<InputLabel>{t('settings.language')}</InputLabel>
<Select
label={t('settings.language')}
value={locale}
onChange={(e) => handleLanguageChange(e.target.value)}
>
{LOCALE_META.map((meta) => (
<MenuItem key={meta.code} value={meta.code}>
{meta.nativeName}
</MenuItem>
))}
</Select>
</FormControl>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.appBehavior')}
</Typography>
<FormControlLabel
control={<Switch checked={config.closeToTray ?? true} onChange={(e) => updateConfig('closeToTray', e.target.checked)} />}
label={t('settings.closeToTray')}
/>
<FormControlLabel
control={<Switch checked={config.autoLaunch ?? false} onChange={(e) => updateConfig('autoLaunch', e.target.checked)} />}
label={t('settings.autoLaunch')}
/>
<FormControlLabel
control={<Switch checked={config.autoInsert ?? true} onChange={(e) => updateConfig('autoInsert', e.target.checked)} />}
label={t('settings.autoInsert')}
/>
<FormControlLabel
control={<Switch checked={config.soundEnabled ?? true} onChange={(e) => updateConfig('soundEnabled', e.target.checked)} />}
label={t('settings.soundEffects')}
/>
</Box>
</TabPanel>
{/* ── 오디오 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={1}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.microphone')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.inputDevice')}</InputLabel>
<Select
label={t('settings.inputDevice')}
value={selectedDeviceId}
onChange={(e) => {
const deviceId = e.target.value
setSelectedDeviceId(deviceId)
window.electronAPI.audio.setSelectedDevice({ deviceId })
}}
startAdornment={<Mic size={18} style={{ color: d3roPalette.text.inactive, marginRight: 8 }} />}
>
{audioDevices.map((device, idx) => (
<MenuItem key={`${device.deviceId}-${idx}`} value={device.deviceId}>
{device.label}{device.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
</MenuItem>
))}
</Select>
</FormControl>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant={micTesting ? 'contained' : 'outlined'}
size="small"
onClick={async () => {
if (micTesting) {
// 메인 프로세스 캡처까지 실제로 중지 — done 이벤트가 구독 해제를 마무리
await window.electronAPI.audio.stopTest()
setMicTesting(false)
setMicLevel(0)
} else {
setMicTesting(true)
setMicLevel(0)
// level 0은 무음/초기화 중일 뿐 — 종료 판정은 done 플래그로만
const unsub = window.electronAPI.audio.onTestLevel((e) => {
setMicLevel(e.level)
if (e.done) {
setMicTesting(false)
setMicLevel(0)
unsub()
}
})
const result = await window.electronAPI.audio.testDevice({ deviceId: 'default' })
if (!result.success) {
setMicTesting(false)
setMicLevel(0)
unsub()
}
}
}}
sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size, minWidth: 80 }}
>
{micTesting ? t('common.stop').toUpperCase() : t('common.test').toUpperCase()}
</Button>
<Box sx={{ flex: 1, height: 8, bgcolor: d3roPalette.bg.inset, borderRadius: '4px', overflow: 'hidden', boxShadow: d3roShadow.inset }}>
<Box sx={{ width: `${Math.min(100, micLevel * 100)}%`, height: '100%', bgcolor: micLevel > 0.7 ? d3roPalette.tag.red : d3roPalette.accent.main, borderRadius: '4px', transition: 'width 100ms ease-out' }} />
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.captionAudio')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.captionSource')}</InputLabel>
<Select
label={t('settings.captionSource')}
value={(config as Record<string, unknown>)['captionAudioSource'] as string ?? 'mic'}
onChange={(e) => {
updateConfig('captionAudioSource' as keyof AppConfig, e.target.value as never)
// CaptionService 설정도 갱신
window.electronAPI.caption.setConfig({ audioSource: e.target.value as 'mic' | 'system' | 'both' })
}}
>
<MenuItem value="mic">{t('settings.captionSource.mic')}</MenuItem>
<MenuItem value="system">{t('settings.captionSource.system')}</MenuItem>
<MenuItem value="both">{t('settings.captionSource.both')}</MenuItem>
</Select>
</FormControl>
<CaptionModelSelect
value={config.captionSttModelId}
models={downloadedSttModels}
onChange={(modelId) => updateConfig('captionSttModelId', modelId)}
/>
<FormControlLabel
sx={{ mr: 0, alignItems: 'flex-start' }}
control={
<Switch
size="small"
checked={config.captionRefineEnabled !== false}
onChange={(e) => updateConfig('captionRefineEnabled', e.target.checked)}
/>
}
label={
<Box sx={{ pt: '2px' }}>
<Typography sx={{ fontSize: d3roTypo.body.size, color: d3roPalette.text.primary }}>
{t('settings.captionRefine')}
</Typography>
<Typography sx={{ fontSize: d3roTypo.meta.size, color: d3roPalette.text.secondary }}>
{t('settings.captionRefine.desc')}
</Typography>
</Box>
}
/>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.textInsert')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.insertMethod')}</InputLabel>
<Select
label={t('settings.insertMethod')}
value={config.insertMethod ?? 'clipboard'}
onChange={(e) => updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')}
>
<MenuItem value="clipboard">{t('settings.insertClipboard')}</MenuItem>
<MenuItem value="keyboard">{t('settings.insertKeyboard')}</MenuItem>
</Select>
</FormControl>
</Box>
</TabPanel>
{/* ── STT 탭 ───────────────────────────────── */}
<TabPanel value={activeTab} index={2}>
<STTTab config={config} updateConfig={updateConfig} />
</TabPanel>
{/* ── LLM 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={3}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* LLM 백엔드 선택 */}
<Box>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px', mb: 1, display: 'block' }}>
AI 처리 백엔드 선택
</Typography>
<FormControl size="small" fullWidth>
<Select
value={config.llmBackend ?? 'local'}
onChange={(e) => {
const val = e.target.value as 'local' | 'online'
updateConfig('llmBackend', val)
updateConfig('appUsageMode', val)
}}
>
<MenuItem value="local">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cpu size={16} color={d3roPalette.tag.green} />
<span>로컬 Ollama 엔진 (100% 무료 / 오프라인 독립 실행)</span>
</Box>
</MenuItem>
<MenuItem value="online">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Cloud size={16} color={d3roPalette.tag.blue} />
<span>D3RO 온라인 클라우드 API</span>
</Box>
</MenuItem>
</Select>
</FormControl>
</Box>
{/* 로컬 Ollama 설정 */}
{config.llmBackend !== 'online' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{/* Ollama 연결 상태 카드 */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={ollamaStatus?.connectionState === 'connected' ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: ollamaStatus?.connectionState === 'connected' ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{ollamaStatus?.connectionState === 'connected'
? `Ollama 연결됨 ${ollamaStatus.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
</Typography>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
설치된 모델: {ollamaModels.length}개 | 현재 활성: {config.llmModelId ?? 'gemma4:e4b'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checkingOllama ? 'animate-spin' : ''} />}
onClick={refreshOllama}
disabled={checkingOllama}
sx={{ fontSize: '11px', fontFamily: d3roFontMono }}
>
연결 확인
</Button>
{ollamaStatus?.connectionState !== 'connected' && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={async () => {
setStartingOllama(true)
setOllamaFeedback(null)
try {
const res = await window.electronAPI.llm.startServer()
if (res.success) {
if (res.data === 'running') setOllamaFeedback('Ollama가 이미 실행 중입니다.')
else if (res.data === 'starting') {
setOllamaFeedback('Ollama 서버를 시작했습니다.')
setTimeout(() => refreshOllama(), 2000)
} else if (res.data === 'not-installed') {
setOllamaFeedback('Ollama가 설치되어 있지 않습니다. 가이드를 열어 설치해 주세요.')
}
}
} finally {
setStartingOllama(false)
}
}}
disabled={startingOllama}
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
>
{startingOllama ? '실행 중' : 'Ollama 실행'}
</Button>
)}
<Button
size="small"
variant="text"
startIcon={<HelpCircle size={13} />}
onClick={() => window.dispatchEvent(new CustomEvent('d3ro:open-ollama-guide'))}
sx={{ fontSize: '11px', color: d3roPalette.accent.main }}
>
가이드/모델 다운로드
</Button>
</Box>
</Paper>
{ollamaFeedback && (
<Typography sx={{ fontSize: '11px', color: d3roPalette.tag.orange }}>
{ollamaFeedback}
</Typography>
)}
{/* 서버 URL & 모델 선택 */}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
<TextField
label={t('settings.ollamaUrl')}
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
fullWidth
size="small"
/>
<FormControl size="small" fullWidth>
<InputLabel>활성 Ollama 모델</InputLabel>
<Select
label="활성 Ollama 모델"
value={config.llmModelId ?? 'gemma4:e4b'}
onChange={(e) => {
const val = e.target.value
updateConfig('llmModelId', val)
window.electronAPI.llm.setModel({ modelId: val })
}}
>
{/* 설치된 모델 목록 */}
{ollamaModels.map((m) => (
<MenuItem key={m.id} value={m.name}>
{m.name} ({Math.round(m.sizeBytes / (1024 * 1024 * 1024) * 10) / 10} GB)
</MenuItem>
))}
{/* 설치된 모델 목록이 없거나 기본 추천 추가 */}
{ollamaModels.length === 0 && (
<MenuItem value="gemma4:e4b">gemma4:e4b (추천)</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'llama3.2:3b') && (
<MenuItem value="llama3.2:3b">llama3.2:3b</MenuItem>
)}
{!ollamaModels.some((m) => m.name === 'qwen2.5:3b') && (
<MenuItem value="qwen2.5:3b">qwen2.5:3b</MenuItem>
)}
</Select>
</FormControl>
</Box>
</Box>
)}
{/* 온라인 API 설정 */}
{config.llmBackend === 'online' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<TextField
label="API 서버 URL"
value={config.onlineApiUrl ?? 'http://localhost:5000'}
onChange={(e) => updateConfig('onlineApiUrl', e.target.value)}
fullWidth
size="small"
/>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
계정 상태: {config.authToken ? `${config.userEmail ?? '로그인됨'} (온라인 API 가용)` : '로그인 필요'}
</Typography>
</Box>
)}
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.postProcess')}
</Typography>
<FormControl size="small">
<InputLabel>{t('settings.defaultAction')}</InputLabel>
<Select
label={t('settings.defaultAction')}
value={config.defaultLLMAction ?? 'refine'}
onChange={(e) => updateConfig('defaultLLMAction', e.target.value)}
>
<MenuItem value="none">{t('settings.action.none')}</MenuItem>
<MenuItem value="refine">{t('settings.action.refine')}</MenuItem>
<MenuItem value="translate">{t('settings.action.translate')}</MenuItem>
<MenuItem value="summarize">{t('settings.action.summarize')}</MenuItem>
<MenuItem value="grammar">{t('settings.action.grammar')}</MenuItem>
<MenuItem value="custom">{t('settings.action.custom')}</MenuItem>
</Select>
</FormControl>
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: d3roTypo.label.size }}>
{t('settings.actionHint')}
</Typography>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* Phase 10: 음성 명령어 토글 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.voiceCommands')}
</Typography>
<FormControlLabel
control={
<Switch
checked={(config as Record<string, unknown>)['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={
<Box>
<Typography variant="body2">{t('settings.voiceCommands')}</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>{t('settings.voiceCommands.desc')}</Typography>
</Box>
}
/>
{/* Phase 10: 화면 컨텍스트 토글 */}
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px', mt: 1 }}>
{t('settings.screenContext')}
</Typography>
<FormControlLabel
control={
<Switch
checked={config.screenContextEnabled ?? false}
onChange={async (_, checked) => {
updateConfig('screenContextEnabled', checked)
await window.electronAPI.context.setEnabled({ enabled: checked })
}}
size="small"
/>
}
label={
<Box>
<Typography variant="body2">{t('settings.screenContext')}</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>{t('settings.screenContext.desc')}</Typography>
</Box>
}
/>
</Box>
</TabPanel>
{/* ── 라이선스 탭 ────────────────────────────── */}
<TabPanel value={activeTab} index={4}>
<InputConsentPanel />
</TabPanel>
<TabPanel value={activeTab} index={5}>
<LicenseTab />
</TabPanel>
{/* ── Cloud Sync 탭 ──────────────────────────── */}
<TabPanel value={activeTab} index={6}>
<CloudSyncSection />
</TabPanel>
{/* ── 정보 탭 ──────────────────────────────── */}
<TabPanel value={activeTab} index={7}>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
D3RO-VOICE
</Typography>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.version')}</Typography>
<Typography variant="body2" color="text.secondary">
{appVersion === null ? '—' : `v${appVersion}`}
</Typography>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.techStack')}</Typography>
<Typography variant="body2" color="text.secondary">{t('settings.about.techStackValue')}</Typography>
</Box>
<Box>
<Typography variant="body2" sx={{ fontWeight: 600 }}>{t('settings.about.voiceEngine')}</Typography>
<Typography variant="body2" color="text.secondary">{t('settings.about.voiceEngineValue')}</Typography>
</Box>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="body2" color="text.secondary" sx={{ fontSize: '11px', mb: 2 }}>
{t('settings.about.description')}
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => {
window.electronAPI.config.set({
key: 'onboardingCompleted',
value: false,
})
onClose()
setTimeout(() => window.location.reload(), 300)
}}
sx={{ fontFamily: d3roFontMono, fontSize: d3roTypo.label.size }}
>
{t('settings.about.restartOnboarding')}
</Button>
</Box>
</TabPanel>
</Box>
</DialogContent>
</Dialog>
</>
)
}