597 lines
22 KiB
TypeScript
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>
|
|
)
|
|
}
|