Phase 10~11 전체 구현: 킬러 피처 5종 + 수익화 시스템
Phase 10 킬러 피처: - MemoService: 태그 CRUD + 마크다운 내보내기 (memo_tags DB) - VoiceCommandService: 키워드→명령어 매칭, 프리셋 4종 - ScreenContextService: PowerShell 활성 윈도우 + Ctrl+C 선택 텍스트 - ChainService: LLM 명령어 순차 실행 파이프라인 - CaptionService: 6초 청크 연속 전사 + 시스템 오디오 루프백 VoiceModeService 파이프라인 통합: - 녹음 시작 → 컨텍스트 캡처 → STT → 키워드 매칭 → LLM(체인/컨텍스트 주입) → 삽입 시스템 오디오 캡처: - setDisplayMediaRequestHandler + audio: 'loopback' (IPC 브릿지) - electron-audio-loopback 패키지 contextIsolation 호환 불가 → 직접 구현 Phase 11 수익화: - LicenseService: Free/Pro/Pro+ 3티어, LemonSqueezy API - Feature Gate: requireFeature/checkFeature/consumeFeature - 일일 쿼터: Free dictation 20/일, LLM 10/일 (SQLite daily_usage) - LicenseModal, ProBadge, UpgradePromptModal UI 디자인 보강: - d3roTypo(13종), d3roShadow(10종), d3roRadius(7종) 토큰 시스템 - ScreenPanel, ButtonGroup DS 컴포넌트 신규 - PhosphorText 4→13종 변형, MetalDial conic-gradient 광택 - 공유 컴포넌트: EmptyStateCard, SearchInput, PageHeader, HistoryEntryCard 기타: - 자막 핫키 SSOT 전체 연동 (Config→Hotkey→VoiceMode→Caption→Settings) - StatusBar 자막 LED + 효과음, 자막 로딩 UI - LLM 상태 이벤트 전파 수정 (폴링 제거 → onStatusChanged) - 커맨드 팝업 "선택 해제" 항목 추가
This commit is contained in:
parent
36d77ca224
commit
a31f96bbb8
97 changed files with 11853 additions and 1143 deletions
|
|
@ -28,9 +28,16 @@ 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 { d3roPalette, d3roFontMono } from '../theme'
|
||||
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
|
||||
|
|
@ -53,10 +60,12 @@ 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 }}>
|
||||
|
|
@ -84,7 +93,7 @@ function HotkeyDisplay({
|
|||
</Stack>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: d3roPalette.text.disabled }}>
|
||||
미설정
|
||||
{notSetLabel}
|
||||
</Typography>
|
||||
)}
|
||||
<IconButton size="small" onClick={onEdit} sx={{ color: d3roPalette.text.inactive, ml: 'auto' }}>
|
||||
|
|
@ -101,6 +110,8 @@ function VoiceModeCard({
|
|||
enabled,
|
||||
onToggle,
|
||||
disabled,
|
||||
enabledLabel,
|
||||
disabledLabel,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
|
|
@ -108,6 +119,8 @@ function VoiceModeCard({
|
|||
enabled: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
disabled?: boolean
|
||||
enabledLabel: string
|
||||
disabledLabel: string
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
|
|
@ -118,7 +131,7 @@ function VoiceModeCard({
|
|||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: '10px',
|
||||
border: 'none',
|
||||
boxShadow: `inset 0 2px 6px rgba(0,0,0,0.4), 0 1px 1px rgba(255,255,255,0.04)`,
|
||||
boxShadow: d3roShadow.inset,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between' }}>
|
||||
|
|
@ -147,7 +160,7 @@ function VoiceModeCard({
|
|||
}
|
||||
label={
|
||||
<Chip
|
||||
label={enabled ? '활성화됨' : '비활성화'}
|
||||
label={enabled ? enabledLabel : disabledLabel}
|
||||
size="small"
|
||||
sx={{
|
||||
fontSize: '10px',
|
||||
|
|
@ -173,6 +186,7 @@ function VoiceModeCard({
|
|||
}
|
||||
|
||||
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)
|
||||
|
|
@ -182,11 +196,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
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'>('dictation')
|
||||
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree' | 'caption'>('dictation')
|
||||
|
||||
// 오디오 디바이스
|
||||
const [audioDevices, setAudioDevices] = useState<AudioDevice[]>([])
|
||||
|
|
@ -199,17 +214,18 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
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, enabledResult]) => {
|
||||
.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)
|
||||
|
|
@ -217,7 +233,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
})
|
||||
.finally(() => setLoading(false))
|
||||
|
||||
// 느린 로드: 오디오 디바이스 (백그라운드, UI 블로킹 안 함)
|
||||
Promise.all([
|
||||
window.electronAPI.audio.getDevices(),
|
||||
window.electronAPI.audio.getSelectedDevice(),
|
||||
|
|
@ -232,12 +247,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
window.electronAPI.config.set({ key, value })
|
||||
}, [])
|
||||
|
||||
// 음성 모드 토글
|
||||
const handleDictationToggle = useCallback(
|
||||
(enabled: boolean) => {
|
||||
setDictationEnabled(enabled)
|
||||
window.electronAPI.hotkey.setEnabled({ enabled })
|
||||
// 받아쓰기 비활성화 시 핸즈프리도 비활성화
|
||||
if (!enabled && handsFreeEnabled) {
|
||||
setHandsFreeEnabled(false)
|
||||
}
|
||||
|
|
@ -247,7 +260,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
|
||||
const handleHandsFreeToggle = useCallback((enabled: boolean) => {
|
||||
if (enabled && !handsFreeBinding) {
|
||||
// 핫키 설정 없으면 녹화 모달 열기
|
||||
setHotkeyModalTarget('handsFree')
|
||||
setHotkeyModalOpen(true)
|
||||
return
|
||||
|
|
@ -255,7 +267,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
setHandsFreeEnabled(enabled)
|
||||
}, [handsFreeBinding])
|
||||
|
||||
// 핫키 저장
|
||||
const handleHotkeySave = useCallback(
|
||||
(binding: HotkeyBinding) => {
|
||||
if (hotkeyModalTarget === 'dictation') {
|
||||
|
|
@ -263,20 +274,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
||||
setDictationEnabled(true)
|
||||
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
||||
} else {
|
||||
} 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') => {
|
||||
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 (
|
||||
|
|
@ -292,7 +311,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
bgcolor: d3roPalette.bg.chassis,
|
||||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: `0 40px 80px -20px rgba(0,0,0,0.8), inset 0 1px 1px rgba(255,255,255,0.08)`,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -310,7 +329,7 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
py: 1.5,
|
||||
}}
|
||||
>
|
||||
Settings
|
||||
{t('settings.title')}
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.inactive }}>
|
||||
<CloseIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
|
|
@ -340,143 +359,145 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label="일반" icon={<KeyboardIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
||||
<Tab label="오디오" icon={<MicIcon sx={{ fontSize: 14 }} />} iconPosition="start" />
|
||||
<Tab label="STT" />
|
||||
<Tab label="LLM" />
|
||||
<Tab label="정보" />
|
||||
<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="받아쓰기"
|
||||
description="누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다."
|
||||
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="키"
|
||||
label={t('settings.key')}
|
||||
notSetLabel={t('settings.notSet')}
|
||||
/>
|
||||
</VoiceModeCard>
|
||||
|
||||
{/* Agent 모드 (더블프레스) */}
|
||||
<VoiceModeCard
|
||||
title="Agent 모드"
|
||||
title={t('settings.agent')}
|
||||
description={
|
||||
dictationBinding
|
||||
? `${dictationBinding.displayLabel}를 두 번 클릭하면 Agent 모드에 진입합니다.`
|
||||
: '받아쓰기 핫키를 먼저 설정하세요.'
|
||||
? 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="원터치 모드"
|
||||
description="눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다."
|
||||
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="키"
|
||||
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 }} />
|
||||
|
||||
{/* UI 설정 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
인터페이스
|
||||
{t('settings.interface')}
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>테마</InputLabel>
|
||||
<InputLabel>{t('settings.theme')}</InputLabel>
|
||||
<Select
|
||||
label="테마"
|
||||
label={t('settings.theme')}
|
||||
value={config.theme ?? 'auto'}
|
||||
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
|
||||
>
|
||||
<MenuItem value="auto">시스템</MenuItem>
|
||||
<MenuItem value="light">라이트</MenuItem>
|
||||
<MenuItem value="dark">다크</MenuItem>
|
||||
<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">Nord</MenuItem>
|
||||
<MenuItem value="solarized">Solarized</MenuItem>
|
||||
<MenuItem value="catppuccin">Catppuccin</MenuItem>
|
||||
<MenuItem value="dracula">Dracula</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>언어</InputLabel>
|
||||
<InputLabel>{t('settings.language')}</InputLabel>
|
||||
<Select
|
||||
label="언어"
|
||||
value={config.language ?? 'ko'}
|
||||
onChange={(e) => updateConfig('language', e.target.value)}
|
||||
label={t('settings.language')}
|
||||
value={locale}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
{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="트레이로 최소화"
|
||||
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="시스템 시작 시 자동 실행"
|
||||
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="전사 후 자동 텍스트 삽입"
|
||||
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="효과음"
|
||||
control={<Switch checked={config.soundEnabled ?? true} onChange={(e) => updateConfig('soundEnabled', e.target.checked)} />}
|
||||
label={t('settings.soundEffects')}
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
|
@ -485,13 +506,13 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<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>입력 장치</InputLabel>
|
||||
<InputLabel>{t('settings.inputDevice')}</InputLabel>
|
||||
<Select
|
||||
label="입력 장치"
|
||||
label={t('settings.inputDevice')}
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => {
|
||||
const deviceId = e.target.value
|
||||
|
|
@ -502,13 +523,12 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
>
|
||||
{audioDevices.map((device, idx) => (
|
||||
<MenuItem key={`${device.deviceId}-${idx}`} value={device.deviceId}>
|
||||
{device.label}{device.isDefault ? ' (기본)' : ''}
|
||||
{device.label}{device.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
{/* 마이크 테스트 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
||||
<Button
|
||||
variant={micTesting ? 'contained' : 'outlined'}
|
||||
|
|
@ -519,7 +539,6 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
setMicLevel(0)
|
||||
} else {
|
||||
setMicTesting(true)
|
||||
// 3초 후 자동 종료
|
||||
const unsub = window.electronAPI.voice.onAudioLevel((e) => {
|
||||
setMicLevel(e.level)
|
||||
})
|
||||
|
|
@ -530,53 +549,53 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
}, 5000)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: '11px',
|
||||
minWidth: 80,
|
||||
}}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: '11px', minWidth: 80 }}
|
||||
>
|
||||
{micTesting ? 'STOP' : 'TEST'}
|
||||
{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: 'inset 0 1px 3px rgba(0,0,0,0.4)',
|
||||
}}
|
||||
>
|
||||
<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 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>삽입 방식</InputLabel>
|
||||
<InputLabel>{t('settings.captionSource')}</InputLabel>
|
||||
<Select
|
||||
label="삽입 방식"
|
||||
value={config.insertMethod ?? 'clipboard'}
|
||||
onChange={(e) =>
|
||||
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
|
||||
}
|
||||
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="clipboard">클립보드 (Ctrl+V)</MenuItem>
|
||||
<MenuItem value="keyboard">키보드 타이핑</MenuItem>
|
||||
<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>
|
||||
|
|
@ -586,28 +605,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Whisper 모델</InputLabel>
|
||||
<InputLabel>{t('settings.whisperModel')}</InputLabel>
|
||||
<Select
|
||||
label="Whisper 모델"
|
||||
label={t('settings.whisperModel')}
|
||||
value={config.sttModelId ?? 'base'}
|
||||
onChange={(e) => updateConfig('sttModelId', e.target.value)}
|
||||
>
|
||||
<MenuItem value="tiny">tiny (39 MB, 가장 빠름)</MenuItem>
|
||||
<MenuItem value="base">base (74 MB, 균형)</MenuItem>
|
||||
<MenuItem value="small">small (244 MB, 양호)</MenuItem>
|
||||
<MenuItem value="medium">medium (769 MB, 우수)</MenuItem>
|
||||
<MenuItem value="large-v3">large-v3 (1.5 GB, 최고)</MenuItem>
|
||||
<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>인식 언어</InputLabel>
|
||||
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
|
||||
<Select
|
||||
label="인식 언어"
|
||||
label={t('settings.sttLanguage')}
|
||||
value={config.sttLanguage ?? 'auto'}
|
||||
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">자동 감지</MenuItem>
|
||||
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
|
|
@ -621,99 +640,143 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
Ollama 서버
|
||||
{t('settings.ollamaServer')}
|
||||
</Typography>
|
||||
|
||||
<TextField
|
||||
label="Ollama 서버 URL"
|
||||
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' }}>
|
||||
Ollama가 실행 중이면 자동으로 연결됩니다.
|
||||
모델은 Ollama에서 직접 pull하세요 (예: ollama pull qwen3:4b).
|
||||
{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>기본 후처리 명령어</InputLabel>
|
||||
<InputLabel>{t('settings.defaultAction')}</InputLabel>
|
||||
<Select
|
||||
label="기본 후처리 명령어"
|
||||
label={t('settings.defaultAction')}
|
||||
value={config.defaultLLMAction ?? 'refine'}
|
||||
onChange={(e) => updateConfig('defaultLLMAction', e.target.value)}
|
||||
>
|
||||
<MenuItem value="none">없음 (원본 텍스트 그대로)</MenuItem>
|
||||
<MenuItem value="refine">다듬기 (문법+자연스러움)</MenuItem>
|
||||
<MenuItem value="translate">번역</MenuItem>
|
||||
<MenuItem value="summarize">요약</MenuItem>
|
||||
<MenuItem value="grammar">문법 교정</MenuItem>
|
||||
<MenuItem value="custom">커스텀 프롬프트</MenuItem>
|
||||
<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' }}>
|
||||
핫키로 녹음 후 전사된 텍스트에 선택한 LLM 후처리가 적용됩니다.
|
||||
Ollama가 연결되어 있을 때만 동작합니다.
|
||||
{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 }}>버전</Typography>
|
||||
<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 }}>기술 스택</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Electron + React 19 + MUI 7 + TypeScript
|
||||
</Typography>
|
||||
<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 }}>음성 엔진</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
STT: faster-whisper (로컬) / LLM: Ollama (로컬)
|
||||
</Typography>
|
||||
<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 }}>
|
||||
Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트.
|
||||
클라우드 의존성 없이 완전 로컬로 동작합니다.
|
||||
{t('settings.about.description')}
|
||||
</Typography>
|
||||
|
||||
<Button
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
// 온보딩 플래그 리셋 → 다음 Settings 닫기 시 온보딩 다시 표시
|
||||
window.electronAPI.config.set({
|
||||
key: 'onboardingCompleted' as keyof import('@shared/types').AppConfig,
|
||||
value: false as never,
|
||||
})
|
||||
onClose()
|
||||
// 약간의 딜레이 후 AppLayout이 온보딩을 다시 감지
|
||||
setTimeout(() => window.location.reload(), 300)
|
||||
}}
|
||||
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
|
||||
>
|
||||
초기 설정 안내 다시 보기
|
||||
{t('settings.about.restartOnboarding')}
|
||||
</Button>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
|
@ -721,14 +784,14 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 핫키 녹화 모달 */}
|
||||
<HotkeyRecordModal
|
||||
open={hotkeyModalOpen}
|
||||
onClose={() => setHotkeyModalOpen(false)}
|
||||
onSave={handleHotkeySave}
|
||||
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding}
|
||||
title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'}
|
||||
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : hotkeyModalTarget === 'handsFree' ? handsFreeBinding : captionBinding}
|
||||
title={hotkeyModalTarget === 'dictation' ? t('hotkey.dictationTitle') : hotkeyModalTarget === 'handsFree' ? t('hotkey.oneTouchTitle') : t('hotkey.captionTitle')}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue