d3ro-voice/apps/desktop/src/renderer/components/STTTab.tsx
Yun Chan 0fbbbc1756
Some checks failed
deploy-site / deploy (push) Failing after 1m15s
fix(release): restore automatic updates by shipping the speech engine on demand
Auto-update could not work at all: the installer was 189 MB because it carried
the local speech engine and ffmpeg, and the download feed rejects uploads over
about 100 MiB, so update metadata could never be published.

The installer now leaves those components out and the app fetches them the first
time they are needed, verifying every part and the joined archive before
installing. The installer is 90.6 MiB, the update feed is published again, and
updates stay small because the engine is not re-sent on every release.

The fetch is visible and recoverable: the download runs with progress, a failed
install cleans up after itself, and Settings > STT shows the runtime status with
a manual download action for when the automatic one cannot run.
2026-09-18 13:51:49 +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>
)
}