d3ro-voice/apps/desktop/src/renderer/components/STTTab.tsx
2026-08-29 18:33:45 +09:00

597 lines
22 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)
// 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)
})
}
})
return () => {
unsubDownload()
}
}, [])
// 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 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.sm,
bgcolor: isSelected ? d3roPalette.bg.elevated : d3roPalette.bg.inset,
border: isSelected ? `1.5px solid ${d3roPalette.accent.main}` : `1px solid ${d3roPalette.border.subtle}`,
boxShadow: isSelected ? d3roShadow.elevated : '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.muted : '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.md,
}}
>
<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.sm,
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.hover },
}}
>
{isDownloading ? `다운로드 중 (${downloadPercent}%)` : '모델 다운로드'}
</Button>
</Paper>
)
}
return null
})()}
</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.sm} !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>
)
}