단발성 평균값 → 100ms 간격 실시간 RMS 스트리밍. AudioCaptureService 직접 시작/5초 자동 중지. 테스트 버튼 누르면 레벨 바가 왔다갔다 반응.
798 lines
32 KiB
TypeScript
798 lines
32 KiB
TypeScript
// 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 '../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 '@shared/types'
|
|
import { Feature } from '@shared/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 <Box sx={{ pt: 2 }}>{children}</Box>
|
|
}
|
|
|
|
// ── 핫키 표시 컴포넌트 ──────────────────────────────────
|
|
function HotkeyDisplay({
|
|
binding,
|
|
onEdit,
|
|
label,
|
|
notSetLabel,
|
|
}: {
|
|
binding: HotkeyBinding | null
|
|
onEdit: () => void
|
|
label: string
|
|
notSetLabel: string
|
|
}): React.ReactElement {
|
|
return (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, minWidth: 40 }}>
|
|
{label}
|
|
</Typography>
|
|
{binding ? (
|
|
<Stack direction="row" spacing={0.5} alignItems="center">
|
|
{binding.displayLabel.split(' + ').map((key) => (
|
|
<Chip
|
|
key={key}
|
|
label={key}
|
|
size="small"
|
|
sx={{
|
|
fontWeight: 700,
|
|
fontSize: '11px',
|
|
bgcolor: d3roPalette.bg.elevated,
|
|
color: d3roPalette.text.primary,
|
|
border: `1px solid ${d3roPalette.border.default}`,
|
|
borderRadius: '6px',
|
|
height: 28,
|
|
}}
|
|
/>
|
|
))}
|
|
</Stack>
|
|
) : (
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
|
|
{notSetLabel}
|
|
</Typography>
|
|
)}
|
|
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
|
|
<EditIcon sx={{ fontSize: 16 }} />
|
|
</IconButton>
|
|
</Box>
|
|
)
|
|
}
|
|
|
|
// ── 음성 모드 카드 ──────────────────────────────────────
|
|
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 (
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
borderRadius: '10px',
|
|
border: 'none',
|
|
boxShadow: d3roShadow.inset,
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
|
<Box sx={{ flex: 1 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 0.5 }}>
|
|
<Typography
|
|
variant="subtitle2"
|
|
sx={{ fontWeight: 700, fontFamily: d3roFontMono, fontSize: '12px', letterSpacing: '0.5px' }}
|
|
>
|
|
{title}
|
|
</Typography>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={enabled}
|
|
onChange={(e) => 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={
|
|
<Chip
|
|
label={enabled ? enabledLabel : disabledLabel}
|
|
size="small"
|
|
sx={{
|
|
fontSize: '10px',
|
|
fontWeight: 700,
|
|
height: 20,
|
|
bgcolor: enabled ? d3roPalette.tag.greenBg : 'transparent',
|
|
color: enabled ? d3roPalette.tag.green : d3roPalette.text.disabled,
|
|
border: enabled ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
|
}}
|
|
/>
|
|
}
|
|
sx={{ ml: 0, mr: 0 }}
|
|
/>
|
|
</Box>
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
|
{description}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
{children && <Box sx={{ mt: 1.5 }}>{children}</Box>}
|
|
</Paper>
|
|
)
|
|
}
|
|
|
|
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
|
|
const { t, locale, setLocale } = useI18n()
|
|
const [activeTab, setActiveTab] = useState(0)
|
|
const [config, setConfig] = useState<Partial<AppConfig>>({})
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
// 음성 모드 상태
|
|
const [dictationEnabled, setDictationEnabled] = useState(true)
|
|
const [dictationBinding, setDictationBinding] = useState<HotkeyBinding | null>(null)
|
|
const [handsFreeEnabled, setHandsFreeEnabled] = useState(false)
|
|
const [handsFreeBinding, setHandsFreeBinding] = useState<HotkeyBinding | null>(null)
|
|
const [captionBinding, setCaptionBinding] = useState<HotkeyBinding | null>(null)
|
|
const [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
|
|
|
|
// 핫키 녹화 모달
|
|
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
|
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
|
|
|
|
// 오디오 디바이스
|
|
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
|
|
const [selectedDeviceId, setSelectedDeviceId] = useState<string>('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)
|
|
})
|
|
}, [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 <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: 700,
|
|
fontSize: '14px',
|
|
letterSpacing: '1px',
|
|
textTransform: 'uppercase',
|
|
color: d3roPalette.accent.amber,
|
|
py: 1.5,
|
|
}}
|
|
>
|
|
{t('settings.title')}
|
|
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
|
|
<CloseIcon sx={{ fontSize: 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: '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 },
|
|
}}
|
|
>
|
|
<Tab label={t('settings.tabs.general')} icon={<KeyboardIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
|
<Tab label={t('settings.tabs.audio')} icon={<MicIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
|
<Tab label={t('settings.tabs.stt')} />
|
|
<Tab label={t('settings.tabs.llm')} />
|
|
<Tab label={t('license.nav')} icon={<LockIcon sx={{ fontSize: 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 }}>
|
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
|
{t('settings.shortcuts')}
|
|
</Typography>
|
|
|
|
<Stack spacing={1.5}>
|
|
<VoiceModeCard
|
|
title={t('settings.dictation')}
|
|
description={t('settings.dictation.desc')}
|
|
enabled={dictationEnabled}
|
|
onToggle={handleDictationToggle}
|
|
enabledLabel={t('settings.enabled')}
|
|
disabledLabel={t('settings.disabled')}
|
|
>
|
|
<HotkeyDisplay
|
|
binding={dictationBinding}
|
|
onEdit={() => openHotkeyModal('dictation')}
|
|
label={t('settings.key')}
|
|
notSetLabel={t('settings.notSet')}
|
|
/>
|
|
</VoiceModeCard>
|
|
|
|
<VoiceModeCard
|
|
title={t('settings.agent')}
|
|
description={
|
|
dictationBinding
|
|
? t('settings.agent.descWithKey', { key: dictationBinding.displayLabel })
|
|
: t('settings.agent.descNoKey')
|
|
}
|
|
enabled={dictationEnabled}
|
|
onToggle={handleDictationToggle}
|
|
disabled={!dictationEnabled}
|
|
enabledLabel={t('settings.enabled')}
|
|
disabledLabel={t('settings.disabled')}
|
|
/>
|
|
|
|
<VoiceModeCard
|
|
title={t('settings.oneTouch')}
|
|
description={t('settings.oneTouch.desc')}
|
|
enabled={handsFreeEnabled}
|
|
onToggle={handleHandsFreeToggle}
|
|
disabled={!dictationEnabled}
|
|
enabledLabel={t('settings.enabled')}
|
|
disabledLabel={t('settings.disabled')}
|
|
>
|
|
<HotkeyDisplay
|
|
binding={handsFreeBinding}
|
|
onEdit={() => openHotkeyModal('handsFree')}
|
|
label={t('settings.key')}
|
|
notSetLabel={t('settings.notSet')}
|
|
/>
|
|
</VoiceModeCard>
|
|
|
|
<VoiceModeCard
|
|
title={t('settings.caption')}
|
|
description={t('settings.caption.desc')}
|
|
enabled={true}
|
|
enabledLabel={t('settings.enabled')}
|
|
disabledLabel={t('settings.disabled')}
|
|
>
|
|
<HotkeyDisplay
|
|
binding={captionBinding}
|
|
onEdit={() => openHotkeyModal('caption')}
|
|
label={t('settings.key')}
|
|
notSetLabel={t('settings.notSet')}
|
|
/>
|
|
</VoiceModeCard>
|
|
</Stack>
|
|
|
|
<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={<MicIcon sx={{ color: d3roPalette.text.inactive, mr: 1, fontSize: 18 }} />}
|
|
>
|
|
{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) {
|
|
setMicTesting(false)
|
|
setMicLevel(0)
|
|
} else {
|
|
setMicTesting(true)
|
|
setMicLevel(0)
|
|
const unsub = window.electronAPI.audio.onTestLevel((e) => {
|
|
setMicLevel(e.level)
|
|
if (e.level === 0) {
|
|
setMicTesting(false)
|
|
unsub()
|
|
}
|
|
})
|
|
await window.electronAPI.audio.testDevice({ deviceId: 'default' })
|
|
}
|
|
}}
|
|
sx={{ fontFamily: d3roFontMono, fontSize: '11px', 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.amber, 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>
|
|
|
|
<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}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<FormControl size="small">
|
|
<InputLabel>{t('settings.whisperModel')}</InputLabel>
|
|
<Select
|
|
label={t('settings.whisperModel')}
|
|
value={config.sttModelId ?? 'base'}
|
|
onChange={(e) => updateConfig('sttModelId', e.target.value)}
|
|
>
|
|
<MenuItem value="tiny">{t('settings.model.tiny')}</MenuItem>
|
|
<MenuItem value="base">{t('settings.model.base')}</MenuItem>
|
|
<MenuItem value="small">{t('settings.model.small')}</MenuItem>
|
|
<MenuItem value="medium">{t('settings.model.medium')}</MenuItem>
|
|
<MenuItem value="large-v3">{t('settings.model.large')}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
|
|
<FormControl size="small">
|
|
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
|
|
<Select
|
|
label={t('settings.sttLanguage')}
|
|
value={config.sttLanguage ?? 'auto'}
|
|
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
|
>
|
|
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
|
|
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
|
|
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
|
|
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
|
|
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
</Box>
|
|
</TabPanel>
|
|
|
|
{/* ── LLM 탭 ──────────────────────────────── */}
|
|
<TabPanel value={activeTab} index={3}>
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
|
{t('settings.ollamaServer')}
|
|
</Typography>
|
|
|
|
<TextField
|
|
label={t('settings.ollamaUrl')}
|
|
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
|
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
|
fullWidth
|
|
/>
|
|
|
|
<Typography variant="body2" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
|
|
{t('settings.ollamaHint')}
|
|
</Typography>
|
|
|
|
<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: '11px' }}>
|
|
{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}>
|
|
<LicenseTab />
|
|
</TabPanel>
|
|
|
|
{/* ── 정보 탭 ──────────────────────────────── */}
|
|
<TabPanel value={activeTab} index={5}>
|
|
<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">v1.0.0</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' as keyof import('@shared/types').AppConfig,
|
|
value: false as never,
|
|
})
|
|
onClose()
|
|
setTimeout(() => window.location.reload(), 300)
|
|
}}
|
|
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
|
|
>
|
|
{t('settings.about.restartOnboarding')}
|
|
</Button>
|
|
</Box>
|
|
</TabPanel>
|
|
</Box>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<HotkeyRecordModal
|
|
open={hotkeyModalOpen}
|
|
onClose={() => 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')}
|
|
/>
|
|
</>
|
|
)
|
|
}
|
|
|