Some checks failed
deploy-site / deploy (push) Failing after 14m16s
The released installer could not start: it carried a better-sqlite3 build for the host Node runtime instead of Electron, so the app died immediately with a module version mismatch when it opened its database. Packaging now proves the Electron build of every runtime-sensitive native module before an installer or archive exists, and installers are produced only from that verified tree, so the mistake cannot pass silently. The release pipelines run the same check. The default local model also pointed at a retired model: a *.gguf name that Ollama cannot serve, while the settings, onboarding, and guide screens recommended an older model. All of them now use the model the service code already preferred.
624 lines
24 KiB
TypeScript
624 lines
24 KiB
TypeScript
// src/renderer/components/OnboardingModal.tsx
|
|
// 첫 실행 온보딩 모달 — 사용 방식 선택 (로컬 Ollama 독립 모드 vs 온라인 클라우드 API)
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import {
|
|
Dialog,
|
|
DialogTitle,
|
|
DialogContent,
|
|
DialogActions,
|
|
Button,
|
|
Typography,
|
|
Box,
|
|
LinearProgress,
|
|
TextField,
|
|
Tab,
|
|
Tabs,
|
|
Paper,
|
|
Chip,
|
|
Alert,
|
|
} from '@mui/material'
|
|
import {
|
|
CheckCircle2,
|
|
AlertCircle,
|
|
Cloud,
|
|
Download,
|
|
Play,
|
|
RefreshCw,
|
|
ExternalLink,
|
|
Cpu,
|
|
Sparkles,
|
|
ArrowRight,
|
|
ShieldCheck,
|
|
} from 'lucide-react'
|
|
import { d3roPalette, d3roRadius, d3roShadow, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
|
|
import { Led } from '@d3ro/ui/components/ds'
|
|
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
|
|
|
|
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
|
|
|
|
interface OnboardingModalProps {
|
|
open: boolean
|
|
onClose: () => void
|
|
}
|
|
|
|
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
|
const [internalOpen, setInternalOpen] = useState(false)
|
|
const [phase, setPhase] = useState<Phase>('select_mode')
|
|
|
|
// Local Ollama State
|
|
const [ollamaStatus, setOllamaStatus] = useState<LLMStatus | null>(null)
|
|
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
|
|
const [activeModel, setActiveModel] = useState<string>('gemma4:e4b')
|
|
const [checkingOllama, setCheckingOllama] = useState(false)
|
|
const [startingOllama, setStartingOllama] = useState(false)
|
|
const [ollamaMsg, setOllamaMsg] = useState<string | null>(null)
|
|
|
|
// Model download state
|
|
const [pullingModel, setPullingModel] = useState<string | null>(null)
|
|
const [pullProgress, setPullProgress] = useState({ status: '', percent: 0 })
|
|
|
|
// Auth State
|
|
const [authTab, setAuthTab] = useState<'login' | 'register'>('login')
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [authError, setAuthError] = useState('')
|
|
const [authLoading, setAuthLoading] = useState(false)
|
|
|
|
// Error State
|
|
const errorMsg = ''
|
|
|
|
const isVisible = open || internalOpen
|
|
|
|
const checkOllama = useCallback(async () => {
|
|
setCheckingOllama(true)
|
|
try {
|
|
const res = await window.electronAPI.llm.checkConnection()
|
|
if (res.success && res.data) {
|
|
setInstalledModels(res.data.models)
|
|
}
|
|
const st = await window.electronAPI.llm.getStatus()
|
|
if (st.success) {
|
|
setOllamaStatus(st.data)
|
|
}
|
|
} finally {
|
|
setCheckingOllama(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
window.electronAPI.config.getAll().then((res) => {
|
|
if (res.success && !res.data.onboardingCompleted) {
|
|
setInternalOpen(true)
|
|
setPhase('select_mode')
|
|
}
|
|
})
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!isVisible) return
|
|
|
|
const unsubPull = window.electronAPI.llm.onPullProgress((e) => {
|
|
setPullingModel(e.modelId)
|
|
setPullProgress({ status: e.status, percent: e.percent })
|
|
if (e.percent >= 100 || e.status === 'success') {
|
|
setTimeout(() => {
|
|
setPullingModel(null)
|
|
checkOllama()
|
|
}, 1200)
|
|
}
|
|
})
|
|
|
|
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
|
|
setOllamaStatus(e.status)
|
|
})
|
|
|
|
return () => {
|
|
unsubPull()
|
|
unsubStatus()
|
|
}
|
|
}, [isVisible, checkOllama])
|
|
|
|
const handleSelectLocalMode = async () => {
|
|
setPhase('local_ollama_setup')
|
|
await window.electronAPI.config.set({ key: 'appUsageMode', value: 'local' })
|
|
await window.electronAPI.config.set({ key: 'llmBackend', value: 'local' })
|
|
await window.electronAPI.config.set({ key: 'sttProvider', value: 'local' })
|
|
await checkOllama()
|
|
// 자동으로 Ollama 기동 시도
|
|
await window.electronAPI.llm.startServer()
|
|
setTimeout(() => checkOllama(), 1500)
|
|
}
|
|
|
|
const handleStartOllama = async () => {
|
|
setStartingOllama(true)
|
|
setOllamaMsg(null)
|
|
try {
|
|
const res = await window.electronAPI.llm.startServer()
|
|
if (res.success) {
|
|
if (res.data === 'running') {
|
|
setOllamaMsg('Ollama가 이미 실행 중입니다.')
|
|
} else if (res.data === 'starting') {
|
|
setOllamaMsg('Ollama 서버를 시작했습니다. 연결 확인 중...')
|
|
setTimeout(() => checkOllama(), 2000)
|
|
} else if (res.data === 'not-installed') {
|
|
setOllamaMsg('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.')
|
|
} else {
|
|
setOllamaMsg('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.')
|
|
}
|
|
}
|
|
} finally {
|
|
setStartingOllama(false)
|
|
}
|
|
}
|
|
|
|
const handlePullModel = async (modelId: string) => {
|
|
setPullingModel(modelId)
|
|
setPullProgress({ status: '다운로드 시작 중...', percent: 0 })
|
|
try {
|
|
await window.electronAPI.llm.pullModel({ modelId })
|
|
await window.electronAPI.llm.setModel({ modelId })
|
|
await window.electronAPI.config.set({ key: 'llmModelId', value: modelId })
|
|
setActiveModel(modelId)
|
|
} catch {
|
|
setPullingModel(null)
|
|
}
|
|
}
|
|
|
|
const handleCompleteLocalSetup = async () => {
|
|
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
|
setPhase('success')
|
|
}
|
|
|
|
const handleAuthSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault()
|
|
setAuthError('')
|
|
setAuthLoading(true)
|
|
|
|
try {
|
|
const res = authTab === 'login'
|
|
? await window.electronAPI.onlineAuth.login({ email, password })
|
|
: await window.electronAPI.onlineAuth.register({ email, password })
|
|
|
|
if (res.success) {
|
|
await window.electronAPI.config.set({ key: 'appUsageMode', value: 'online' })
|
|
await window.electronAPI.config.set({ key: 'llmBackend', value: 'online' })
|
|
await window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
|
setPhase('success')
|
|
} else {
|
|
setAuthError(res.error?.message ?? '인증에 실패하였습니다.')
|
|
}
|
|
} catch (err) {
|
|
setAuthError(`오류: ${err instanceof Error ? err.message : String(err)}`)
|
|
} finally {
|
|
setAuthLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleClose = () => {
|
|
setInternalOpen(false)
|
|
onClose()
|
|
}
|
|
|
|
const isOllamaConnected = ollamaStatus?.connectionState === 'connected'
|
|
const hasInstalledLlm = installedModels.length > 0
|
|
|
|
if (!isVisible) return <></>
|
|
|
|
return (
|
|
<Dialog
|
|
open={isVisible}
|
|
onClose={() => {}}
|
|
maxWidth="md"
|
|
fullWidth
|
|
PaperProps={{
|
|
sx: {
|
|
borderRadius: d3roRadius.card,
|
|
backgroundColor: d3roPalette.bg.card,
|
|
border: `1px solid ${d3roPalette.border.default}`,
|
|
p: 1,
|
|
},
|
|
}}
|
|
>
|
|
<DialogTitle
|
|
sx={{
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 1.5,
|
|
...typoSx('heading'),
|
|
color: d3roPalette.text.primary,
|
|
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
|
pb: 1.5,
|
|
}}
|
|
>
|
|
{phase === 'success' ? (
|
|
<CheckCircle2 size={24} style={{ color: d3roPalette.tag.green }} />
|
|
) : phase === 'failed' ? (
|
|
<AlertCircle size={24} style={{ color: d3roPalette.tag.red }} />
|
|
) : (
|
|
<Sparkles size={24} style={{ color: d3roPalette.accent.main }} />
|
|
)}
|
|
D3RO Voice 시작 가이드
|
|
</DialogTitle>
|
|
|
|
<DialogContent sx={{ py: 3 }}>
|
|
{/* ── 1. 사용 모드 선택 ── */}
|
|
{phase === 'select_mode' && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary }}>
|
|
D3RO Voice를 어떤 환경으로 사용하시겠습니까? 언제든지 환경설정에서 변경할 수 있습니다.
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 2 }}>
|
|
{/* 로컬 AI 독립 모드 (강력 권장) */}
|
|
<Paper
|
|
elevation={0}
|
|
onClick={handleSelectLocalMode}
|
|
sx={{
|
|
p: 2.5,
|
|
cursor: 'pointer',
|
|
borderRadius: d3roRadius.card,
|
|
bgcolor: d3roPalette.bg.elevated,
|
|
border: `2px solid ${d3roPalette.accent.main}`,
|
|
transition: 'all 150ms ease-out',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'space-between',
|
|
'&:hover': {
|
|
bgcolor: d3roPalette.bg.inset,
|
|
transform: 'translateY(-2px)',
|
|
boxShadow: d3roShadow.dialog,
|
|
},
|
|
}}
|
|
>
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Cpu size={22} color={d3roPalette.accent.main} />
|
|
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
|
|
로컬 AI 모드
|
|
</Typography>
|
|
</Box>
|
|
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
|
|
</Box>
|
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
|
|
<b>Ollama</b>와 로컬 <b>Whisper STT</b>를 사용하여 PC 내부에서 완전히 독립 실행됩니다.
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
{[
|
|
'인터넷 연결 없이 오프라인 작동',
|
|
'음성/텍스트 데이터 외부 유출 제로 (완벽한 보안)',
|
|
'API 비용 없이 무제한 전사 및 AI 다듬기',
|
|
].map((item) => (
|
|
<Box key={item} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<ShieldCheck size={14} color={d3roPalette.tag.green} />
|
|
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.primary }}>
|
|
{item}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Button
|
|
variant="contained"
|
|
fullWidth
|
|
endIcon={<ArrowRight size={14} />}
|
|
sx={{
|
|
mt: 2.5,
|
|
fontFamily: d3roFontSans,
|
|
fontWeight: 500,
|
|
bgcolor: d3roPalette.accent.main,
|
|
color: d3roPalette.bg.app,
|
|
'&:hover': { bgcolor: d3roPalette.accent.light },
|
|
}}
|
|
>
|
|
로컬 AI 모드로 시작
|
|
</Button>
|
|
</Paper>
|
|
|
|
{/* 온라인 클라우드 모드 */}
|
|
<Paper
|
|
elevation={0}
|
|
onClick={() => setPhase('online_auth')}
|
|
sx={{
|
|
p: 2.5,
|
|
cursor: 'pointer',
|
|
borderRadius: d3roRadius.card,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
border: `1px solid ${d3roPalette.border.default}`,
|
|
transition: 'all 150ms ease-out',
|
|
display: 'flex',
|
|
flexDirection: 'column',
|
|
justifyContent: 'space-between',
|
|
'&:hover': {
|
|
bgcolor: d3roPalette.bg.elevated,
|
|
borderColor: d3roPalette.border.subtle,
|
|
},
|
|
}}
|
|
>
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<Cloud size={22} color={d3roPalette.tag.blue} />
|
|
<Typography sx={{ fontWeight: 600, fontSize: '15px', color: d3roPalette.text.primary }}>
|
|
온라인 클라우드 모드
|
|
</Typography>
|
|
</Box>
|
|
<Chip label="계정 로그인" size="small" sx={{ fontSize: '10px', height: 20 }} />
|
|
</Box>
|
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 2, lineHeight: 1.5 }}>
|
|
클라우드 서버 계정으로 로그인하여 AI 음성인식 및 고급 모델을 사용합니다.
|
|
</Typography>
|
|
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 0.75 }}>
|
|
{[
|
|
'로컬 모델 설치 불필요 (저사양 PC용)',
|
|
'클라우드 동기화 및 다중 기기 연동',
|
|
'D3RO 계정 로그인 필요',
|
|
].map((item) => (
|
|
<Box key={item} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<CheckCircle2 size={14} color={d3roPalette.tag.blue} />
|
|
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.primary }}>
|
|
{item}
|
|
</Typography>
|
|
</Box>
|
|
))}
|
|
</Box>
|
|
</Box>
|
|
|
|
<Button
|
|
variant="outlined"
|
|
fullWidth
|
|
endIcon={<ArrowRight size={14} />}
|
|
sx={{
|
|
mt: 2.5,
|
|
fontFamily: d3roFontSans,
|
|
borderColor: d3roPalette.border.default,
|
|
color: d3roPalette.text.primary,
|
|
}}
|
|
>
|
|
온라인 계정 로그인
|
|
</Button>
|
|
</Paper>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── 2. 로컬 Ollama 온보딩 스텝 ── */}
|
|
{phase === 'local_ollama_setup' && (
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
|
|
{/* 연결 상태 박스 */}
|
|
<Paper
|
|
elevation={0}
|
|
sx={{
|
|
p: 2,
|
|
bgcolor: d3roPalette.bg.inset,
|
|
border: `1px solid ${isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange}`,
|
|
borderRadius: d3roRadius.small,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'space-between',
|
|
}}
|
|
>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
|
<Led color={isOllamaConnected ? 'green' : 'amber'} pulse={!isOllamaConnected} size={9} />
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: isOllamaConnected ? d3roPalette.tag.green : d3roPalette.tag.orange }}>
|
|
{isOllamaConnected
|
|
? `Ollama 로컬 엔진 연결 완료 ${ollamaStatus?.serverVersion ? `(v${ollamaStatus.serverVersion})` : ''}`
|
|
: 'Ollama 로컬 엔진 확인 필요'}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary }}>
|
|
{isOllamaConnected
|
|
? `설치된 모델: ${installedModels.length}개 | 활성 모델: ${activeModel}`
|
|
: 'Ollama가 실행되어 있지 않거나 설치되지 않았습니다.'}
|
|
</Typography>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', gap: 1 }}>
|
|
<Button
|
|
size="small"
|
|
variant="outlined"
|
|
startIcon={<RefreshCw size={13} className={checkingOllama ? 'animate-spin' : ''} />}
|
|
onClick={checkOllama}
|
|
disabled={checkingOllama}
|
|
sx={{ fontSize: '11px' }}
|
|
>
|
|
연결 재확인
|
|
</Button>
|
|
{!isOllamaConnected && (
|
|
<Button
|
|
size="small"
|
|
variant="contained"
|
|
startIcon={<Play size={13} />}
|
|
onClick={handleStartOllama}
|
|
disabled={startingOllama}
|
|
sx={{ fontSize: '11px', bgcolor: d3roPalette.accent.main, color: d3roPalette.bg.app, fontWeight: 500 }}
|
|
>
|
|
Ollama 자동 실행
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
|
|
{ollamaMsg && (
|
|
<Alert severity={isOllamaConnected ? 'success' : 'info'} sx={{ py: 0.5 }}>
|
|
{ollamaMsg}
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Ollama 미설치 시 가이드 */}
|
|
{!isOllamaConnected && (
|
|
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.elevated, borderRadius: d3roRadius.small, border: `1px solid ${d3roPalette.border.subtle}` }}>
|
|
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.accent.main }}>
|
|
Ollama가 아직 설치되어 있지 않다면:
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
|
1. 아래 버튼을 눌러 공식 웹사이트에서 Ollama를 다운로드하여 설치해 주세요.<br />
|
|
2. 설치 후 '연결 재확인' 버튼을 누르면 D3RO Voice가 자동으로 감지합니다.
|
|
</Typography>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
endIcon={<ExternalLink size={14} />}
|
|
onClick={() => window.electronAPI.system.openExternal({ url: 'https://ollama.com/download' })}
|
|
sx={{ fontFamily: d3roFontMono, fontSize: '11px', borderColor: d3roPalette.accent.main, color: d3roPalette.accent.light }}
|
|
>
|
|
ollama.com/download 열기
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
|
|
{/* 추천 모델 설치 섹션 */}
|
|
<Box>
|
|
<Typography sx={{ fontWeight: 500, fontSize: '13px', mb: 1, color: d3roPalette.text.primary }}>
|
|
기본 한국어 AI 모델 설치 (Gemma 2 2B)
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.secondary, mb: 1.5 }}>
|
|
텍스트 정제, 요약, 번역에 최적화된 초경량 모델입니다. (크기: 약 1.6GB)
|
|
</Typography>
|
|
|
|
{pullingModel ? (
|
|
<Box sx={{ p: 2, bgcolor: d3roPalette.bg.inset, borderRadius: d3roRadius.small }}>
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
|
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.accent.light }}>
|
|
{pullProgress.status || '모델 다운로드 중...'}
|
|
</Typography>
|
|
<Typography sx={{ fontSize: '12px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
|
|
{pullProgress.percent}%
|
|
</Typography>
|
|
</Box>
|
|
<LinearProgress variant="determinate" value={pullProgress.percent} sx={{ height: 6, borderRadius: 3 }} />
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
|
|
<Button
|
|
variant="contained"
|
|
startIcon={<Download size={14} />}
|
|
onClick={() => handlePullModel('gemma4:e4b')}
|
|
disabled={!isOllamaConnected}
|
|
sx={{
|
|
fontWeight: 500,
|
|
bgcolor: d3roPalette.accent.main,
|
|
color: d3roPalette.bg.app,
|
|
'&:hover': { bgcolor: d3roPalette.accent.light },
|
|
}}
|
|
>
|
|
{hasInstalledLlm ? 'Gemma 2 (2B) 추가 다운로드' : 'Gemma 2 (2B) 1-클릭 다운로드'}
|
|
</Button>
|
|
|
|
{hasInstalledLlm && (
|
|
<Typography sx={{ fontSize: '12px', color: d3roPalette.tag.green, display: 'flex', alignItems: 'center', gap: 0.5 }}>
|
|
<CheckCircle2 size={15} /> 이미 로컬 AI 모델이 준비되어 있습니다.
|
|
</Typography>
|
|
)}
|
|
</Box>
|
|
)}
|
|
</Box>
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between', pt: 1, borderTop: `1px solid ${d3roPalette.border.subtle}` }}>
|
|
<Button variant="text" size="small" onClick={() => setPhase('select_mode')}>
|
|
뒤로 가기
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleCompleteLocalSetup}
|
|
disabled={!isOllamaConnected}
|
|
sx={{ fontWeight: 500 }}
|
|
>
|
|
설정 완료 및 시작하기
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── 3. 온라인 계정 인증 ── */}
|
|
{phase === 'online_auth' && (
|
|
<Box component="form" onSubmit={handleAuthSubmit}>
|
|
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
|
|
온라인 서비스를 이용하기 위해 D3RO 계정으로 로그인해 주세요.
|
|
</Typography>
|
|
|
|
<Tabs
|
|
value={authTab}
|
|
onChange={(_, v) => setAuthTab(v)}
|
|
sx={{ mb: 2, borderBottom: `1px solid ${d3roPalette.border.subtle}` }}
|
|
>
|
|
<Tab label="로그인" value="login" />
|
|
<Tab label="회원가입" value="register" />
|
|
</Tabs>
|
|
|
|
{authError && (
|
|
<Typography sx={{ color: d3roPalette.tag.red, mb: 2, ...typoSx('small') }}>
|
|
{authError}
|
|
</Typography>
|
|
)}
|
|
|
|
<TextField
|
|
fullWidth
|
|
label="이메일 주소"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
required
|
|
sx={{ mb: 2 }}
|
|
/>
|
|
<TextField
|
|
fullWidth
|
|
label="비밀번호"
|
|
type="password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
required
|
|
sx={{ mb: 3 }}
|
|
/>
|
|
|
|
<Box sx={{ display: 'flex', justifyContent: 'space-between' }}>
|
|
<Button variant="text" onClick={() => setPhase('select_mode')}>
|
|
뒤로 가기
|
|
</Button>
|
|
<Button type="submit" variant="contained" disabled={authLoading}>
|
|
{authLoading ? '처리 중...' : authTab === 'login' ? '로그인 완료' : '회원가입 완료'}
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
)}
|
|
|
|
{/* ── 4. 설정 완료 ── */}
|
|
{phase === 'success' && (
|
|
<Box sx={{ textAlign: 'center', py: 3 }}>
|
|
<CheckCircle2 size={48} style={{ color: d3roPalette.tag.green, margin: '0 auto 16px' }} />
|
|
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.tag.green, mb: 1, fontSize: '18px' }}>
|
|
D3RO Voice 설정이 완료되었습니다!
|
|
</Typography>
|
|
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, maxWidth: 440, mx: 'auto' }}>
|
|
언제든지 우측 상단 <b>설정</b>에서 STT 공급자 및 Ollama 모델을 변경할 수 있습니다.
|
|
</Typography>
|
|
</Box>
|
|
)}
|
|
|
|
{phase === 'failed' && (
|
|
<Box sx={{ py: 2 }}>
|
|
<Typography sx={{ ...typoSx('body'), color: d3roPalette.tag.red, mb: 2 }}>
|
|
설정 중 오류가 발생했습니다: {errorMsg}
|
|
</Typography>
|
|
<Button variant="contained" onClick={() => setPhase('select_mode')}>
|
|
다시 시도
|
|
</Button>
|
|
</Box>
|
|
)}
|
|
</DialogContent>
|
|
|
|
{phase === 'success' && (
|
|
<DialogActions sx={{ px: 3, pb: 2 }}>
|
|
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 500 }}>
|
|
D3RO Voice 시작하기
|
|
</Button>
|
|
</DialogActions>
|
|
)}
|
|
</Dialog>
|
|
)
|
|
}
|