Some checks failed
deploy-site / deploy (push) Failing after 1m15s
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.
692 lines
26 KiB
TypeScript
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>
|
|
)
|
|
}
|