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.
870 lines
38 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|