d3ro-voice/apps/desktop/src/renderer/components/STTTab.tsx
Yun Chan da0da98285 fix(suggestion): paste after the shortcut keys are released and keep typing detection stable
Ctrl+Alt+Enter pasted while Ctrl+Alt were still down, so the target app got
Ctrl+Alt+V; accepting now closes the panel and waits for the modifiers to be
released. Candidates are accepted on pointer press because the list is
redrawn as new candidates stream in, which swallowed clicks.

The typing gate identified the focused field by its bounds, so chat boxes
that grow while typing looked like a new field on every keystroke and were
reported as "not typing". Fields are now keyed by window, control type and
name, and a mouse click re-baselines the text instead. The decision log
includes both gate values.

The live-caption model selector moves to the caption section of the General
tab, next to the other caption settings.
2026-09-24 22:01:10 +09:00

692 lines
26 KiB
TypeScript

// src/renderer/components/STTTab.tsx
// STT 서비스 선택 및 다중 AI 공급자(OpenAI, Groq, Deepgram, AssemblyAI, Google 등) 연동 탭
import { useState, useEffect, useCallback } from 'react'
import {
Box,
Typography,
Select,
MenuItem,
FormControl,
InputLabel,
TextField,
Button,
Switch,
FormControlLabel,
Divider,
Paper,
Stack,
Chip,
Accordion,
AccordionSummary,
AccordionDetails,
CircularProgress,
IconButton,
InputAdornment,
Alert,
} from '@mui/material'
import {
ChevronDown,
Key,
HelpCircle,
CheckCircle2,
AlertCircle,
Eye,
EyeOff,
Cpu,
Cloud,
Zap,
Sparkles,
RefreshCw,
HardDriveDownload,
} from 'lucide-react'
import { d3roPalette, d3roFontMono, d3roShadow, d3roRadius, d3roTypo } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import type {
AppConfig,
STTProviderType,
STTProviderInfo,
STTProviderConfig,
STTModel,
} from '@d3ro/core/types'
import { CodexOAuthGuideModal } from './CodexOAuthGuideModal'
interface STTTabProps {
config: Partial<AppConfig>
updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void
}
export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElement {
const { t } = useI18n()
const [providers, setProviders] = useState<STTProviderInfo[]>([])
const [activeProvider, setActiveProvider] = useState<STTProviderType>(config.sttProvider || 'local')
const [providerConfig, setProviderConfig] = useState<STTProviderConfig>({})
const [showApiKey, setShowApiKey] = useState(false)
const [guideOpen, setGuideOpen] = useState(false)
// Local models
const [localModels, setLocalModels] = useState<STTModel[]>([])
const [downloadingModelId, setDownloadingModelId] = useState<string | null>(null)
const [downloadPercent, setDownloadPercent] = useState<number>(0)
// 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다
type RuntimeComponentName = 'sidecar' | 'ffmpeg'
interface RuntimeStatusRow {
component: RuntimeComponentName
installed: boolean
path: string
sizeBytes: number
}
const [runtimeStatus, setRuntimeStatus] = useState<RuntimeStatusRow[]>([])
const [runtimeBusy, setRuntimeBusy] = useState<RuntimeComponentName | null>(null)
const [runtimePercent, setRuntimePercent] = useState(0)
// Test connection state
const [testing, setTesting] = useState(false)
const [testResult, setTestResult] = useState<{ success: boolean; latencyMs: number; message: string } | null>(null)
// Load providers and local models
useEffect(() => {
window.electronAPI.stt.getProviders().then((res) => {
if (res.success && res.data) setProviders(res.data)
})
window.electronAPI.stt.getActiveProvider().then((res) => {
if (res.success && res.data) setActiveProvider(res.data)
})
window.electronAPI.stt.getModels().then((res) => {
if (res.success && res.data) setLocalModels(res.data)
})
const unsubDownload = window.electronAPI.stt.onDownloadProgress((e) => {
setDownloadingModelId(e.modelId)
setDownloadPercent(e.percent)
if (e.percent >= 100) {
setDownloadingModelId(null)
window.electronAPI.stt.getModels().then((res) => {
if (res.success && res.data) setLocalModels(res.data)
})
}
})
const loadRuntime = () => {
window.electronAPI.runtime.getStatus().then((res) => {
if (res.success && res.data) setRuntimeStatus(res.data)
})
}
loadRuntime()
const unsubRuntime = window.electronAPI.runtime.onProgress((e) => {
if (e.component !== 'sidecar' && e.component !== 'ffmpeg') return
if (e.phase === 'done') {
setRuntimeBusy(null)
setRuntimePercent(100)
loadRuntime()
return
}
setRuntimeBusy(e.component)
setRuntimePercent(e.percent)
})
return () => {
unsubDownload()
}
unsubDownload()
unsubRuntime()
}, [])
// Load specific provider config when activeProvider changes
useEffect(() => {
if (!activeProvider) return
window.electronAPI.stt.getProviderConfig({ provider: activeProvider }).then((res) => {
if (res.success && res.data) {
setProviderConfig(res.data)
}
})
setTestResult(null)
}, [activeProvider])
const handleProviderChange = useCallback(
(provider: STTProviderType) => {
setActiveProvider(provider)
updateConfig('sttProvider', provider)
window.electronAPI.stt.setProvider({ provider })
},
[updateConfig]
)
const handleProviderConfigFieldChange = useCallback(
(field: keyof STTProviderConfig, value: unknown) => {
const updated = { ...providerConfig, [field]: value }
setProviderConfig(updated)
window.electronAPI.stt.setProviderConfig({
provider: activeProvider,
config: updated,
})
},
[activeProvider, providerConfig]
)
const handleEnsureRuntime = useCallback(async (component: RuntimeComponentName) => {
setRuntimeBusy(component)
setRuntimePercent(0)
try {
const res = await window.electronAPI.runtime.ensure({ component })
if (!res.success) setRuntimePercent(0)
} finally {
setRuntimeBusy(null)
const status = await window.electronAPI.runtime.getStatus()
if (status.success && status.data) setRuntimeStatus(status.data)
}
}, [])
const handleTestConnection = useCallback(async () => {
setTesting(true)
setTestResult(null)
try {
const res = await window.electronAPI.stt.testConnection({
provider: activeProvider,
apiKey: providerConfig.apiKey,
baseUrl: providerConfig.baseUrl,
modelId: providerConfig.modelId,
})
if (res.success && res.data) {
setTestResult(res.data)
}
} finally {
setTesting(false)
}
}, [activeProvider, providerConfig])
const handleDownloadModel = useCallback(async (modelId: string) => {
setDownloadingModelId(modelId)
setDownloadPercent(0)
await window.electronAPI.stt.downloadModel({ modelId })
}, [])
const currentProviderInfo = providers.find((p) => p.id === activeProvider)
return (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── 1. 공급자 선택 헤더 ── */}
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
STT 음성 인식 서비스 선택
</Typography>
<Button
size="small"
variant="text"
startIcon={<HelpCircle size={14} />}
onClick={() => setGuideOpen(true)}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
color: d3roPalette.accent.main,
py: 0.2,
px: 1,
}}
>
OpenAI & Codex OAuth 설정 안내
</Button>
</Box>
{/* ── 공급자 카드 그리드 ── */}
<Box
sx={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
gap: 1.2,
}}
>
{providers.map((p) => {
const isSelected = p.id === activeProvider
return (
<Paper
key={p.id}
elevation={0}
onClick={() => handleProviderChange(p.id)}
sx={{
p: 1.5,
cursor: 'pointer',
borderRadius: d3roRadius.small,
bgcolor: isSelected ? d3roPalette.bg.elevated : d3roPalette.bg.inset,
border: isSelected ? `1.5px solid ${d3roPalette.accent.main}` : `1px solid ${d3roPalette.border.subtle}`,
boxShadow: isSelected ? d3roShadow.card : 'none',
transition: 'all 120ms ease-out',
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
'&:hover': {
bgcolor: d3roPalette.bg.elevated,
borderColor: isSelected ? d3roPalette.accent.main : d3roPalette.border.default,
},
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
{p.isCloud ? (
p.id === 'groq' ? (
<Zap size={15} color={d3roPalette.tag.green} />
) : p.id === 'openai' ? (
<Sparkles size={15} color={d3roPalette.accent.main} />
) : (
<Cloud size={15} color={d3roPalette.text.secondary} />
)
) : (
<Cpu size={15} color={d3roPalette.tag.blue} />
)}
{isSelected && (
<CheckCircle2 size={14} color={d3roPalette.accent.main} />
)}
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontSize: '12px', mb: 0.2 }}>
{p.name}
</Typography>
</Box>
<Chip
label={p.badge}
size="small"
sx={{
mt: 1,
height: 18,
fontSize: '9px',
fontWeight: 500,
bgcolor: isSelected ? d3roPalette.accent.dim : 'transparent',
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
}}
/>
</Paper>
)
})}
</Box>
</Box>
{/* ── 2. 선택된 공급자 빠른 설정 (Simple Mode) ── */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.inner,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
<Box>
<Typography variant="subtitle2" sx={{ fontWeight: 500, fontFamily: d3roFontMono, color: d3roPalette.accent.main }}>
{currentProviderInfo?.name}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
{currentProviderInfo?.description}
</Typography>
</Box>
<Chip
label={currentProviderInfo?.isCloud ? '클라우드 API' : '로컬 오프라인'}
size="small"
sx={{
height: 20,
fontSize: '10px',
fontWeight: 500,
bgcolor: currentProviderInfo?.isCloud ? d3roPalette.tag.purpleBg : d3roPalette.tag.greenBg,
color: currentProviderInfo?.isCloud ? d3roPalette.tag.purple : d3roPalette.tag.green,
}}
/>
</Box>
{/* Local Whisper 설정 */}
{activeProvider === 'local' ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<FormControl size="small" fullWidth>
<InputLabel>{t('settings.whisperModel')}</InputLabel>
<Select
label={t('settings.whisperModel')}
value={config.sttModelId ?? 'large-v3-turbo'}
onChange={(e) => {
updateConfig('sttModelId', e.target.value)
window.electronAPI.stt.setModel({ modelId: e.target.value })
}}
>
{localModels.map((m) => (
<MenuItem key={m.id} value={m.id}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', width: '100%', alignItems: 'center' }}>
<span>{m.name} ({Math.round(m.sizeBytes / 1_000_000)} MB)</span>
{m.downloaded ? (
<Chip label="설치됨" size="small" sx={{ height: 18, fontSize: '10px', bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
) : (
<Chip label="미다운로드" size="small" sx={{ height: 18, fontSize: '10px', bgcolor: d3roPalette.tag.orangeBg, color: d3roPalette.tag.orange }} />
)}
</Box>
</MenuItem>
))}
</Select>
</FormControl>
{/* 모델 다운로드 진행 바 또는 다운로드 버튼 */}
{(() => {
const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo'))
if (selected && !selected.downloaded) {
const isDownloading = downloadingModelId === selected.id
return (
<Paper
elevation={0}
sx={{
p: 1.5,
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{selected.name} 모델 다운로드가 필요합니다
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
크기: 약 {Math.round(selected.sizeBytes / 1_000_000)} MB
</Typography>
</Box>
<Button
size="small"
variant="contained"
disabled={isDownloading}
startIcon={isDownloading ? <CircularProgress size={14} /> : <HardDriveDownload size={14} />}
onClick={() => handleDownloadModel(selected.id)}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 500,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.light },
}}
>
{isDownloading ? `다운로드 중 (${downloadPercent}%)` : '모델 다운로드'}
</Button>
</Paper>
)
}
return null
})()}
{/* 로컬 AI 런타임(엔진/ffmpeg): 설치본에는 없고 처음 필요할 때 내려받는다 */}
{runtimeStatus.map((row) => {
const busy = runtimeBusy === row.component
return (
<Paper
key={row.component}
elevation={0}
sx={{
p: 1.5,
bgcolor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.default}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 2,
}}
>
<Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{row.component === 'sidecar'
? '로컬 음성 엔진 (faster-whisper)'
: '미디어 변환기 (ffmpeg)'}
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.secondary }}>
{row.installed
? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB`
: busy
? `다운로드 중 (${runtimePercent}%)`
: '설치되지 않음 — 로컬 전사에 필요합니다'}
</Typography>
</Box>
{busy ? (
<CircularProgress size={16} />
) : (
<Button
size="small"
variant={row.installed ? 'outlined' : 'contained'}
startIcon={<HardDriveDownload size={14} />}
onClick={() => handleEnsureRuntime(row.component)}
sx={{ fontFamily: d3roFontMono, fontSize: '11px' }}
>
{row.installed ? '다시 설치' : '내려받기'}
</Button>
)}
</Paper>
)
})}
</Box>
) : (
/* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
<TextField
label={`${currentProviderInfo?.name} API Key`}
type={showApiKey ? 'text' : 'password'}
value={providerConfig.apiKey ?? ''}
onChange={(e) => handleProviderConfigFieldChange('apiKey', e.target.value)}
placeholder={
activeProvider === 'openai'
? 'sk-proj-...'
: activeProvider === 'groq'
? 'gsk_...'
: 'API Key를 입력하세요'
}
fullWidth
size="small"
InputProps={{
startAdornment: <Key size={16} style={{ color: d3roPalette.text.inactive, marginRight: 8 }} />,
endAdornment: (
<InputAdornment position="end">
<IconButton size="small" onClick={() => setShowApiKey((v) => !v)} edge="end">
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
</IconButton>
</InputAdornment>
),
}}
/>
<Button
variant="outlined"
size="small"
onClick={handleTestConnection}
disabled={testing}
startIcon={testing ? <CircularProgress size={14} /> : <RefreshCw size={14} />}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 500,
minWidth: 105,
height: 40,
whiteSpace: 'nowrap',
}}
>
{testing ? '테스트 중...' : '연결 테스트'}
</Button>
</Box>
{/* Test result feedback banner */}
{testResult && (
<Alert
severity={testResult.success ? 'success' : 'error'}
icon={testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
sx={{
py: 0.5,
px: 1.5,
fontSize: '12px',
bgcolor: testResult.success ? d3roPalette.tag.greenBg : d3roPalette.tag.redBg,
color: testResult.success ? d3roPalette.tag.green : d3roPalette.tag.red,
border: `1px solid ${testResult.success ? d3roPalette.tag.green : d3roPalette.tag.red}`,
}}
>
{testResult.message}
</Alert>
)}
</Box>
)}
</Paper>
{/* ── 3. 전문가 상세 설정 (Accordion - Collapsed by default) ── */}
<Accordion
elevation={0}
sx={{
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.border.subtle}`,
borderRadius: `${d3roRadius.small} !important`,
'&:before': { display: 'none' },
}}
>
<AccordionSummary
expandIcon={<ChevronDown size={18} color={d3roPalette.text.inactive} />}
sx={{
minHeight: 40,
py: 0.5,
'& .MuiAccordionSummary-content': { my: 0.5 },
}}
>
<Typography
variant="subtitle2"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.label.size,
fontWeight: 500,
color: d3roPalette.text.secondary,
letterSpacing: '0.5px',
}}
>
전문가 상세 설정 (엔드포인트, 커스텀 모델, 폴백 옵션)
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0, pb: 2, px: 2 }}>
<Stack spacing={2}>
<Divider sx={{ borderColor: d3roPalette.border.subtle, mb: 1 }} />
{/* Custom Model ID */}
<TextField
label="사용할 모델 ID (Model ID)"
size="small"
value={providerConfig.modelId ?? currentProviderInfo?.defaultModel ?? ''}
onChange={(e) => handleProviderConfigFieldChange('modelId', e.target.value)}
helperText={`기본값: ${currentProviderInfo?.defaultModel || '자동'}`}
fullWidth
/>
{/* Custom Base URL (for proxies or custom servers) */}
{activeProvider !== 'local' && (
<TextField
label="API 엔드포인트 URL (Base URL)"
size="small"
value={providerConfig.baseUrl ?? currentProviderInfo?.defaultBaseUrl ?? ''}
onChange={(e) => handleProviderConfigFieldChange('baseUrl', e.target.value)}
placeholder={currentProviderInfo?.defaultBaseUrl}
helperText="프록시 또는 사내 프라이빗 서버를 사용하는 경우 변경하세요."
fullWidth
/>
)}
{/* Fallback to local Whisper toggle */}
<FormControlLabel
control={
<Switch
checked={config.sttFallbackToLocal ?? true}
onChange={(e) => updateConfig('sttFallbackToLocal', e.target.checked)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.green },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.green,
},
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontSize: d3roTypo.small.size, fontWeight: 600 }}>
로컬 Whisper 자동 폴백 (Auto Fallback)
</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive }}>
클라우드 STT 요청이 실패(인터넷 끊김, 쿼터 초과, 키 오류 등)할 경우 로컬 Whisper로 안전하게 자동 전환합니다.
</Typography>
</Box>
}
/>
</Stack>
</AccordionDetails>
</Accordion>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
{/* ── 4. 언어 및 화자 구분 공통 설정 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
음성 인식 공통 설정
</Typography>
<FormControl size="small" fullWidth>
<InputLabel>{t('settings.sttLanguage')}</InputLabel>
<Select
label={t('settings.sttLanguage')}
value={config.sttLanguage ?? 'auto'}
onChange={(e) => {
updateConfig('sttLanguage', e.target.value)
window.electronAPI.stt.setLanguage({ language: 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>
<Divider sx={{ borderColor: d3roPalette.border.subtle }} />
<Typography variant="overline" sx={{ color: d3roPalette.text.label, letterSpacing: '1.5px' }}>
{t('settings.diarization')}
</Typography>
<TextField
label={t('settings.hfToken')}
type="password"
value={(config as Record<string, unknown>)['hfToken'] as string ?? ''}
onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)}
fullWidth
size="small"
helperText={t('settings.hfTokenHint')}
/>
<FormControlLabel
control={
<Switch
checked={(config as Record<string, unknown>)['diarizationEnabled'] as boolean ?? false}
onChange={(e) => updateConfig('diarizationEnabled' as keyof AppConfig, e.target.checked as never)}
size="small"
sx={{
'& .MuiSwitch-switchBase.Mui-checked': { color: d3roPalette.tag.purple },
'& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': {
backgroundColor: d3roPalette.tag.purple,
},
}}
/>
}
label={
<Box>
<Typography variant="body2" sx={{ fontSize: d3roTypo.small.size }}>{t('settings.diarization')}</Typography>
<Typography variant="caption" sx={{ color: d3roPalette.text.inactive, fontSize: '11px' }}>
{t('settings.diarizationHint')}
</Typography>
</Box>
}
/>
</Box>
{/* Codex OAuth & API Key Setup Guide Modal */}
<CodexOAuthGuideModal open={guideOpen} onClose={() => setGuideOpen(false)} />
</Box>
)
}