Phase 8: SSOT 리팩토링 + HotkeyRecordModal + Dashboard 재작성
- d3roPalette 11개 토큰 추가 (sidebar, chassis, inactive 등) - DS 컴포넌트 6개 + 페이지 5개 매직넘버 → 팔레트 참조 (0개 잔여) - HotkeyRecordModal 신규: 커스텀 핫키 녹화 모달 - SettingsModal 재작성: 음성 모드 3개(받아쓰기/Agent/원터치) + 핫키 변경 - DashboardPage 재작성: Hero + 통계 4카드 + CRT 서비스 상태 + 히스토리 날짜 그룹핑 - recording-tip 색상 수정: #1F5DF2(파란) → #f25b29(앰버) - 설계 문서: phase-8.md, speakly-settings-ui.md
This commit is contained in:
parent
f41fc277e3
commit
28371a9d1a
19 changed files with 1879 additions and 403 deletions
|
|
@ -1,7 +1,7 @@
|
|||
// src/renderer/components/SettingsModal.tsx
|
||||
// 설계서 03: Settings React Modal (일반/오디오/핫키 탭)
|
||||
// 설계서 03: Settings React Modal — General(음성 모드+핫키)/Audio/STT/LLM 탭
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
|
|
@ -18,11 +18,18 @@ import {
|
|||
IconButton,
|
||||
Divider,
|
||||
InputLabel,
|
||||
FormControl
|
||||
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 { d3roPalette } from '../theme'
|
||||
import type { ThemeMode, AppConfig } from '@shared/types'
|
||||
import { HotkeyRecordModal } from './HotkeyRecordModal'
|
||||
import type { ThemeMode, AppConfig, HotkeyBinding } from '@shared/types'
|
||||
|
||||
interface SettingsModalProps {
|
||||
open: boolean
|
||||
|
|
@ -40,183 +47,454 @@ function TabPanel({ children, value, index }: TabPanelProps): React.ReactElement
|
|||
return <Box sx={{ pt: 2 }}>{children}</Box>
|
||||
}
|
||||
|
||||
// ── 핫키 표시 컴포넌트 ──────────────────────────────────
|
||||
function HotkeyDisplay({
|
||||
binding,
|
||||
onEdit,
|
||||
label,
|
||||
}: {
|
||||
binding: HotkeyBinding | null
|
||||
onEdit: () => void
|
||||
label: 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 }}>
|
||||
미설정
|
||||
</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,
|
||||
children,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
enabled: boolean
|
||||
onToggle: (enabled: boolean) => void
|
||||
disabled?: boolean
|
||||
children?: React.ReactNode
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<Paper
|
||||
elevation={0}
|
||||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
borderRadius: '12px',
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<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: 600 }}>
|
||||
{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 ? '활성화됨' : '비활성화'}
|
||||
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.secondary, fontSize: '12px' }}>
|
||||
{description}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
{children && <Box sx={{ mt: 1.5 }}>{children}</Box>}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
export function SettingsModal({ open, onClose }: SettingsModalProps): React.ReactElement {
|
||||
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 [hotkeyGlobalEnabled, setHotkeyGlobalEnabled] = useState(true)
|
||||
|
||||
// 핫키 녹화 모달
|
||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
||||
const [hotkeyModalTarget, setHotkeyModalTarget] = useState<'dictation' | 'handsFree'>('dictation')
|
||||
|
||||
// 설정 로드
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setLoading(true)
|
||||
window.electronAPI.config
|
||||
.getAll()
|
||||
.then((result) => {
|
||||
if (result.success) {
|
||||
setConfig(result.data)
|
||||
|
||||
Promise.all([
|
||||
window.electronAPI.config.getAll(),
|
||||
window.electronAPI.hotkey.getDictationShortcut(),
|
||||
window.electronAPI.hotkey.getHandsFreeShortcut(),
|
||||
window.electronAPI.hotkey.isEnabled(),
|
||||
])
|
||||
.then(([configResult, dictResult, hfResult, enabledResult]) => {
|
||||
if (configResult.success) setConfig(configResult.data)
|
||||
if (dictResult.success && dictResult.data) setDictationBinding(dictResult.data)
|
||||
if (hfResult.success && hfResult.data) setHandsFreeBinding(hfResult.data)
|
||||
if (enabledResult.success) {
|
||||
setHotkeyGlobalEnabled(enabledResult.data)
|
||||
setDictationEnabled(enabledResult.data)
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false))
|
||||
}, [open])
|
||||
|
||||
const updateConfig = (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => {
|
||||
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 {
|
||||
setHandsFreeBinding(binding)
|
||||
window.electronAPI.hotkey.setHandsFreeShortcut({ binding })
|
||||
setHandsFreeEnabled(true)
|
||||
}
|
||||
},
|
||||
[hotkeyModalTarget]
|
||||
)
|
||||
|
||||
const openHotkeyModal = useCallback((target: 'dictation' | 'handsFree') => {
|
||||
setHotkeyModalTarget(target)
|
||||
setHotkeyModalOpen(true)
|
||||
}, [])
|
||||
|
||||
if (loading) return <Dialog open={open} onClose={onClose}><DialogContent /></Dialog>
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
||||
Settings
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
<DialogContent>
|
||||
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)}>
|
||||
<Tab label="General" />
|
||||
<Tab label="Audio" />
|
||||
<Tab label="STT" />
|
||||
<Tab label="LLM" />
|
||||
</Tabs>
|
||||
<>
|
||||
<Dialog open={open} onClose={onClose} fullWidth maxWidth="sm">
|
||||
<DialogTitle sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontWeight: 700 }}>
|
||||
설정
|
||||
<IconButton onClick={onClose} size="small" sx={{ color: d3roPalette.text.label }}>
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</DialogTitle>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
<DialogContent>
|
||||
<Tabs value={activeTab} onChange={(_, v: number) => setActiveTab(v)}>
|
||||
<Tab label="일반" icon={<KeyboardIcon sx={{ fontSize: 16 }} />} iconPosition="start" />
|
||||
<Tab label="오디오" />
|
||||
<Tab label="STT" />
|
||||
<Tab label="LLM" />
|
||||
</Tabs>
|
||||
|
||||
{/* General */}
|
||||
<TabPanel value={activeTab} index={0}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Theme</InputLabel>
|
||||
<Select
|
||||
label="Theme"
|
||||
value={config.theme ?? 'auto'}
|
||||
onChange={(e) => updateConfig('theme', e.target.value as ThemeMode)}
|
||||
>
|
||||
<MenuItem value="auto">System</MenuItem>
|
||||
<MenuItem value="light">Light</MenuItem>
|
||||
<MenuItem value="dark">Dark</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
{/* ── 일반 탭 ─────────────────────────────── */}
|
||||
<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' }}>
|
||||
단축키
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>Language</InputLabel>
|
||||
<Select
|
||||
label="Language"
|
||||
value={config.language ?? 'ko'}
|
||||
onChange={(e) => updateConfig('language', e.target.value)}
|
||||
>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Stack spacing={1.5}>
|
||||
{/* 받아쓰기 모드 */}
|
||||
<VoiceModeCard
|
||||
title="받아쓰기"
|
||||
description="누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다."
|
||||
enabled={dictationEnabled}
|
||||
onToggle={handleDictationToggle}
|
||||
>
|
||||
<HotkeyDisplay
|
||||
binding={dictationBinding}
|
||||
onEdit={() => openHotkeyModal('dictation')}
|
||||
label="키"
|
||||
/>
|
||||
</VoiceModeCard>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.closeToTray ?? true}
|
||||
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
|
||||
{/* Agent 모드 (더블프레스) */}
|
||||
<VoiceModeCard
|
||||
title="Agent 모드"
|
||||
description={
|
||||
dictationBinding
|
||||
? `${dictationBinding.displayLabel}를 두 번 클릭하면 Agent 모드에 진입합니다.`
|
||||
: '받아쓰기 핫키를 먼저 설정하세요.'
|
||||
}
|
||||
enabled={dictationEnabled}
|
||||
onToggle={handleDictationToggle}
|
||||
disabled={!dictationEnabled}
|
||||
/>
|
||||
}
|
||||
label="Close to tray"
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.autoInsert ?? true}
|
||||
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Auto-insert text after transcription"
|
||||
/>
|
||||
{/* 원터치 모드 (핸즈프리) */}
|
||||
<VoiceModeCard
|
||||
title="원터치 모드"
|
||||
description="눌러서 시작, 다시 눌러서 중지. 별도 단축키가 필요합니다."
|
||||
enabled={handsFreeEnabled}
|
||||
onToggle={handleHandsFreeToggle}
|
||||
disabled={!dictationEnabled}
|
||||
>
|
||||
<HotkeyDisplay
|
||||
binding={handsFreeBinding}
|
||||
onEdit={() => openHotkeyModal('handsFree')}
|
||||
label="키"
|
||||
/>
|
||||
</VoiceModeCard>
|
||||
</Stack>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.soundEnabled ?? true}
|
||||
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Sound effects"
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* Audio */}
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Microphone device selection will be available in a future update.
|
||||
Currently using the system default microphone.
|
||||
</Typography>
|
||||
{/* UI 설정 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
인터페이스
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>Insert Method</InputLabel>
|
||||
<Select
|
||||
label="Insert Method"
|
||||
value={config.insertMethod ?? 'clipboard'}
|
||||
onChange={(e) =>
|
||||
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
|
||||
<FormControl size="small">
|
||||
<InputLabel>테마</InputLabel>
|
||||
<Select
|
||||
label="테마"
|
||||
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>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>언어</InputLabel>
|
||||
<Select
|
||||
label="언어"
|
||||
value={config.language ?? 'ko'}
|
||||
onChange={(e) => updateConfig('language', e.target.value)}
|
||||
>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
|
||||
|
||||
{/* 앱 동작 */}
|
||||
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
|
||||
앱 동작
|
||||
</Typography>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.closeToTray ?? true}
|
||||
onChange={(e) => updateConfig('closeToTray', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<MenuItem value="clipboard">Clipboard (Ctrl+V)</MenuItem>
|
||||
<MenuItem value="keyboard">Keyboard typing</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
label="트레이로 최소화"
|
||||
/>
|
||||
|
||||
{/* STT */}
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Whisper Model</InputLabel>
|
||||
<Select
|
||||
label="Whisper Model"
|
||||
value={config.sttModelId ?? 'base'}
|
||||
onChange={(e) => updateConfig('sttModelId', e.target.value)}
|
||||
>
|
||||
<MenuItem value="tiny">tiny (39 MB, fastest)</MenuItem>
|
||||
<MenuItem value="base">base (74 MB, balanced)</MenuItem>
|
||||
<MenuItem value="small">small (244 MB, better)</MenuItem>
|
||||
<MenuItem value="medium">medium (769 MB, good)</MenuItem>
|
||||
<MenuItem value="large-v3">large-v3 (1.5 GB, best)</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.autoLaunch ?? false}
|
||||
onChange={(e) => updateConfig('autoLaunch', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="시스템 시작 시 자동 실행"
|
||||
/>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>Language</InputLabel>
|
||||
<Select
|
||||
label="Language"
|
||||
value={config.sttLanguage ?? 'auto'}
|
||||
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">Auto-detect</MenuItem>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="zh">中文</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.autoInsert ?? true}
|
||||
onChange={(e) => updateConfig('autoInsert', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="전사 후 자동 텍스트 삽입"
|
||||
/>
|
||||
|
||||
{/* LLM */}
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Ollama Server URL"
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={config.soundEnabled ?? true}
|
||||
onChange={(e) => updateConfig('soundEnabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="효과음"
|
||||
/>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
LLM model selection will be available after Ollama integration (Phase 4).
|
||||
</Typography>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{/* ── 오디오 탭 ────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={1}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
현재 시스템 기본 마이크를 사용합니다.
|
||||
마이크 선택 및 테스트 기능은 추후 업데이트에서 제공됩니다.
|
||||
</Typography>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>삽입 방식</InputLabel>
|
||||
<Select
|
||||
label="삽입 방식"
|
||||
value={config.insertMethod ?? 'clipboard'}
|
||||
onChange={(e) =>
|
||||
updateConfig('insertMethod', e.target.value as 'clipboard' | 'keyboard')
|
||||
}
|
||||
>
|
||||
<MenuItem value="clipboard">클립보드 (Ctrl+V)</MenuItem>
|
||||
<MenuItem value="keyboard">키보드 타이핑</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── STT 탭 ───────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={2}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<FormControl size="small">
|
||||
<InputLabel>Whisper 모델</InputLabel>
|
||||
<Select
|
||||
label="Whisper 모델"
|
||||
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>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl size="small">
|
||||
<InputLabel>인식 언어</InputLabel>
|
||||
<Select
|
||||
label="인식 언어"
|
||||
value={config.sttLanguage ?? 'auto'}
|
||||
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
|
||||
>
|
||||
<MenuItem value="auto">자동 감지</MenuItem>
|
||||
<MenuItem value="ko">한국어</MenuItem>
|
||||
<MenuItem value="en">English</MenuItem>
|
||||
<MenuItem value="ja">日本語</MenuItem>
|
||||
<MenuItem value="zh">中文</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
|
||||
{/* ── LLM 탭 ──────────────────────────────── */}
|
||||
<TabPanel value={activeTab} index={3}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<TextField
|
||||
label="Ollama 서버 URL"
|
||||
value={config.ollamaServerUrl ?? 'http://localhost:11434'}
|
||||
onChange={(e) => updateConfig('ollamaServerUrl', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
Ollama가 실행 중이면 자동으로 연결됩니다.
|
||||
모델은 Ollama에서 직접 pull하세요 (예: ollama pull qwen3:4b).
|
||||
</Typography>
|
||||
</Box>
|
||||
</TabPanel>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 핫키 녹화 모달 */}
|
||||
<HotkeyRecordModal
|
||||
open={hotkeyModalOpen}
|
||||
onClose={() => setHotkeyModalOpen(false)}
|
||||
onSave={handleHotkeySave}
|
||||
currentBinding={hotkeyModalTarget === 'dictation' ? dictationBinding : handsFreeBinding}
|
||||
title={hotkeyModalTarget === 'dictation' ? '받아쓰기 단축키 설정' : '원터치 모드 단축키 설정'}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue