// 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 updateConfig: (key: keyof AppConfig, value: AppConfig[keyof AppConfig]) => void } export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElement { const { t } = useI18n() const [providers, setProviders] = useState([]) const [activeProvider, setActiveProvider] = useState(config.sttProvider || 'local') const [providerConfig, setProviderConfig] = useState({}) const [showApiKey, setShowApiKey] = useState(false) const [guideOpen, setGuideOpen] = useState(false) // Local models const [localModels, setLocalModels] = useState([]) const [downloadingModelId, setDownloadingModelId] = useState(null) const [downloadPercent, setDownloadPercent] = useState(0) // 로컬 AI 런타임(사이드카 엔진/ffmpeg) — 설치본에는 없고 필요할 때 내려받는다 type RuntimeComponentName = 'sidecar' | 'ffmpeg' interface RuntimeStatusRow { component: RuntimeComponentName installed: boolean path: string sizeBytes: number } const [runtimeStatus, setRuntimeStatus] = useState([]) const [runtimeBusy, setRuntimeBusy] = useState(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 ( {/* ── 1. 공급자 선택 헤더 ── */} STT 음성 인식 서비스 선택 {/* ── 공급자 카드 그리드 ── */} {providers.map((p) => { const isSelected = p.id === activeProvider return ( 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, }, }} > {p.isCloud ? ( p.id === 'groq' ? ( ) : p.id === 'openai' ? ( ) : ( ) ) : ( )} {isSelected && ( )} {p.name} ) })} {/* ── 2. 선택된 공급자 빠른 설정 (Simple Mode) ── */} {currentProviderInfo?.name} {currentProviderInfo?.description} {/* Local Whisper 설정 */} {activeProvider === 'local' ? ( {t('settings.whisperModel')} {/* 모델 다운로드 진행 바 또는 다운로드 버튼 */} {(() => { const selected = localModels.find((m) => m.id === (config.sttModelId ?? 'large-v3-turbo')) if (selected && !selected.downloaded) { const isDownloading = downloadingModelId === selected.id return ( {selected.name} 모델 다운로드가 필요합니다 크기: 약 {Math.round(selected.sizeBytes / 1_000_000)} MB ) } return null })()} {/* 로컬 AI 런타임(엔진/ffmpeg): 설치본에는 없고 처음 필요할 때 내려받는다 */} {runtimeStatus.map((row) => { const busy = runtimeBusy === row.component return ( {row.component === 'sidecar' ? '로컬 음성 엔진 (faster-whisper)' : '미디어 변환기 (ffmpeg)'} {row.installed ? `설치됨 · ${Math.round(row.sizeBytes / 1_000_000)} MB` : busy ? `다운로드 중 (${runtimePercent}%)` : '설치되지 않음 — 로컬 전사에 필요합니다'} {busy ? ( ) : ( )} ) })} ) : ( /* Cloud STT (OpenAI, Groq, Deepgram, AssemblyAI, Google, Custom) 설정 */ handleProviderConfigFieldChange('apiKey', e.target.value)} placeholder={ activeProvider === 'openai' ? 'sk-proj-...' : activeProvider === 'groq' ? 'gsk_...' : 'API Key를 입력하세요' } fullWidth size="small" InputProps={{ startAdornment: , endAdornment: ( setShowApiKey((v) => !v)} edge="end"> {showApiKey ? : } ), }} /> {/* Test result feedback banner */} {testResult && ( : } 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} )} )} {/* ── 3. 전문가 상세 설정 (Accordion - Collapsed by default) ── */} } sx={{ minHeight: 40, py: 0.5, '& .MuiAccordionSummary-content': { my: 0.5 }, }} > 전문가 상세 설정 (엔드포인트, 커스텀 모델, 폴백 옵션) {/* Custom Model ID */} handleProviderConfigFieldChange('modelId', e.target.value)} helperText={`기본값: ${currentProviderInfo?.defaultModel || '자동'}`} fullWidth /> {/* Custom Base URL (for proxies or custom servers) */} {activeProvider !== 'local' && ( handleProviderConfigFieldChange('baseUrl', e.target.value)} placeholder={currentProviderInfo?.defaultBaseUrl} helperText="프록시 또는 사내 프라이빗 서버를 사용하는 경우 변경하세요." fullWidth /> )} {/* Fallback to local Whisper toggle */} 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={ 로컬 Whisper 자동 폴백 (Auto Fallback) 클라우드 STT 요청이 실패(인터넷 끊김, 쿼터 초과, 키 오류 등)할 경우 로컬 Whisper로 안전하게 자동 전환합니다. } /> {/* ── 4. 언어 및 화자 구분 공통 설정 ── */} 음성 인식 공통 설정 {t('settings.sttLanguage')} {t('settings.diarization')} )['hfToken'] as string ?? ''} onChange={(e) => updateConfig('hfToken' as keyof AppConfig, e.target.value as never)} fullWidth size="small" helperText={t('settings.hfTokenHint')} /> )['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={ {t('settings.diarization')} {t('settings.diarizationHint')} } /> {/* Codex OAuth & API Key Setup Guide Modal */} setGuideOpen(false)} /> ) }