feat: complete release preparation, 10+ ad mediation, CI/CD, and docker deployment
Some checks failed
CI Pipeline / Code Quality & Typecheck (push) Waiting to run
CI Pipeline / Test Suite (macos-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (ubuntu-latest) (push) Blocked by required conditions
CI Pipeline / Test Suite (windows-latest) (push) Blocked by required conditions
CI Pipeline / Build Validation (admin) (push) Blocked by required conditions
CI Pipeline / Build Validation (desktop) (push) Blocked by required conditions
Deploy Landing Page / deploy (push) Blocked by required conditions
Deploy Landing Page / build (push) Waiting to run
Release & Packaging Pipeline / Build & Publish Admin Docker Image (push) Failing after 8s
Release & Code Signing CA Pipeline / build-and-sign-windows (push) Failing after 1m51s
Build macOS / Build & Package (macOS) (push) Failing after 4s
Build macOS / Build & Package (macOS)-1 (push) Failing after 5s
Release & Code Signing CA Pipeline / build-and-sign-macos (push) Failing after 3s
Release & Packaging Pipeline / Package macOS Desktop App (push) Failing after 4s
Release & Packaging Pipeline / Package Windows Desktop App (push) Failing after 2m28s
Release & Packaging Pipeline / Publish Official GitHub Release (push) Has been skipped

This commit is contained in:
Yun Chan 2026-08-20 11:12:05 +09:00
parent 5cd1de6859
commit 708e20f747
406 changed files with 42464 additions and 6199 deletions

View file

@ -1,16 +1,7 @@
// src/renderer/components/OnboardingModal.tsx
// 첫 실행 온보딩 모달 — 기본 모델 미설치 시 다운로드 유도.
//
// 2단계 부트스트랩:
// 1) LLM: gemma4:e4b (Ollama pull, ~9.6GB)
// 2) STT: Whisper large-v3-turbo (sidecar 사전 다운로드, ~1.6GB)
// 필요한 단계만 실행하며(멱등), 모두 성공 시 config.onboardingCompleted=true 저장.
//
// 두 경로로 열림:
// 1) AppLayout의 첫 실행 감지(onboardingCompleted=false)
// 2) 런타임 중 모델 미설치 감지 (주기적 polling)
// 첫 실행 온보딩 모달 — 사용 방식 선택 (로컬 Ollama 독립 모드 vs 온라인 클라우드 API)
import { useState, useEffect, useCallback, useRef } from 'react'
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
@ -20,251 +11,214 @@ import {
Typography,
Box,
LinearProgress,
TextField,
Tab,
Tabs,
Paper,
Chip,
Alert,
} from '@mui/material'
import { CheckCircle2, AlertCircle, CloudDownload } from 'lucide-react'
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
import { useI18n } from '@d3ro/i18n'
import {
CheckCircle2,
AlertCircle,
Cloud,
HardDrive,
Lock,
Download,
Play,
RefreshCw,
ExternalLink,
Cpu,
Sparkles,
ArrowRight,
ShieldCheck,
} from 'lucide-react'
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import type { LLMModel, LLMStatus, STTModel } from '@d3ro/core/types'
const DEFAULT_LLM_MODEL = 'gemma4:e4b'
const FALLBACK_STT_MODEL = 'large-v3-turbo'
type StepKind = 'llm' | 'stt'
type Phase = 'prompt' | 'downloading' | 'success' | 'failed'
interface NeededSteps {
steps: StepKind[]
sttModelId: string
}
type Phase = 'select_mode' | 'local_ollama_setup' | 'online_auth' | 'success' | 'failed'
interface OnboardingModalProps {
open: boolean
onClose: () => void
}
/** 설치가 필요한 단계 목록을 계산한다 (멱등 — 이미 설치된 단계는 제외) */
async function computeNeededSteps(): Promise<NeededSteps> {
const steps: StepKind[] = []
// 1) LLM 모델 존재 여부
try {
const llmResult = await window.electronAPI.llm.getModels()
const hasLlm =
llmResult.success && llmResult.data.some((m) => m.id === DEFAULT_LLM_MODEL)
if (!hasLlm) steps.push('llm')
} catch {
steps.push('llm')
}
// 2) STT 모델 다운로드 여부 (현재 설정된 모델 기준)
let sttModelId = FALLBACK_STT_MODEL
try {
const activeResult = await window.electronAPI.stt.getActiveModel()
if (activeResult.success && activeResult.data) {
sttModelId = activeResult.data
}
const modelsResult = await window.electronAPI.stt.getModels()
const downloaded =
modelsResult.success &&
modelsResult.data.some((m) => m.id === sttModelId && m.downloaded)
if (!downloaded) steps.push('stt')
} catch {
steps.push('stt')
}
return { steps, sttModelId }
}
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
const { t } = useI18n()
const [internalOpen, setInternalOpen] = useState(false)
const [phase, setPhase] = useState<Phase>('prompt')
const [neededSteps, setNeededSteps] = useState<StepKind[]>([])
const [stepIndex, setStepIndex] = useState(0)
const [sttModelId, setSttModelId] = useState(FALLBACK_STT_MODEL)
const [percent, setPercent] = useState(0)
const [status, setStatus] = useState('')
const [detail, setDetail] = useState('')
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>('gemma2:2b')
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, setErrorMsg] = useState('')
// 진행률 없는 상태(무결성 검증 등) — 인디터미넌트 바로 "멈춘 것처럼" 보이는 문제 방지
const [busyStatus, setBusyStatus] = useState(false)
const activeStepRef = useRef<StepKind | null>(null)
const runningRef = useRef(false)
const isVisible = open || internalOpen
// 모델 존재 여부 체크 — 없으면 auto-open
const checkModels = useCallback(async (): Promise<void> => {
const checkOllama = useCallback(async () => {
setCheckingOllama(true)
try {
const needed = await computeNeededSteps()
setSttModelId(needed.sttModelId)
if (needed.steps.length > 0) {
setNeededSteps(needed.steps)
setPhase('prompt')
setInternalOpen(true)
} else {
setInternalOpen(false)
const res = await window.electronAPI.llm.checkConnection()
if (res.success && res.data) {
setInstalledModels(res.data.models)
}
} catch {
setPhase('prompt')
setInternalOpen(true)
}
}, [])
useEffect(() => {
const initialCheck = setTimeout(() => void checkModels(), 2000)
const interval = setInterval(() => {
if (phase === 'prompt') void checkModels()
}, 15000)
return () => {
clearTimeout(initialCheck)
clearInterval(interval)
}
}, [checkModels, phase])
// LLM pull 진행률 구독
useEffect(() => {
const unsub = window.electronAPI.llm.onPullProgress((e) => {
if (activeStepRef.current !== 'llm') return
if (e.modelId !== DEFAULT_LLM_MODEL) return
setStatus(e.status)
if (e.percent > 0) setPercent(e.percent)
// total 없는 상태 라인(pulling manifest / verifying / writing manifest)은
// 바이트 진행률이 없음 — 인디터미넌트로 표시
setBusyStatus(!e.total || e.total === 0)
})
return () => {
unsub()
}
}, [])
// STT 다운로드 진행률 구독
useEffect(() => {
const unsub = window.electronAPI.stt.onDownloadProgress((e) => {
if (activeStepRef.current !== 'stt') return
setPercent(e.percent)
if (e.totalBytes > 0) {
setDetail(
t('onboarding.progressDetail', {
downloaded: String(Math.round(e.downloadedBytes / 1e6)),
total: String(Math.round(e.totalBytes / 1e6)),
}),
)
const st = await window.electronAPI.llm.getStatus()
if (st.success) {
setOllamaStatus(st.data)
}
})
return () => {
unsub()
}
}, [t])
const handleDownload = useCallback(async (): Promise<void> => {
// 중복 실행 가드 (연타/이중 트리거 방지)
if (runningRef.current) return
runningRef.current = true
setPhase('downloading')
setErrorMsg('')
try {
// 재시도 시 이미 끝난 단계를 스킵하도록 매번 재계산
const needed = await computeNeededSteps()
setSttModelId(needed.sttModelId)
setNeededSteps(needed.steps)
for (let i = 0; i < needed.steps.length; i++) {
const step = needed.steps[i]
setStepIndex(i)
setPercent(0)
setStatus('')
setDetail('')
setBusyStatus(false)
activeStepRef.current = step
const result =
step === 'llm'
? await window.electronAPI.llm.pullModel({ modelId: DEFAULT_LLM_MODEL })
: await window.electronAPI.stt.downloadModel({ modelId: needed.sttModelId })
if (!result.success) {
setPhase('failed')
setErrorMsg(result.error?.message ?? 'unknown')
return
}
setPercent(100)
}
setPhase('success')
// 온보딩 완료 플래그 저장
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
} finally {
activeStepRef.current = null
runningRef.current = false
setCheckingOllama(false)
}
}, [])
const handleClose = useCallback(() => {
if (phase === 'downloading') return
setInternalOpen(false)
onClose()
}, [phase, onClose])
useEffect(() => {
window.electronAPI.config.getAll().then((res) => {
if (res.success && !res.data.onboardingCompleted) {
setInternalOpen(true)
setPhase('select_mode')
}
})
}, [])
if (!isVisible) return <></>
useEffect(() => {
if (!isVisible) return
const isDownloading = phase === 'downloading'
const isSuccess = phase === 'success'
const isFailed = phase === 'failed'
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 colorSuccess = d3roPalette.tag.green
const colorDanger = d3roPalette.tag.red
const colorAccent = d3roPalette.accent.main
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
setOllamaStatus(e.status)
})
const currentStep: StepKind | null = isDownloading
? (neededSteps[stepIndex] ?? null)
: null
return () => {
unsubPull()
unsubStatus()
}
}, [isVisible, checkOllama])
// Ollama 원시 status 문자열 → 친화적 문구 (알려진 것만 매핑, 그 외 원문)
const formatStatus = (raw: string): string => {
if (raw.startsWith('pulling manifest')) return t('onboarding.statusPreparing')
if (raw.startsWith('verifying')) return t('onboarding.statusVerifying')
if (raw.startsWith('writing manifest') || raw === 'success')
return t('onboarding.statusFinalizing')
return raw
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 renderStepInfo = (step: StepKind): React.ReactElement => (
<Box
key={step}
sx={{
p: 2,
borderRadius: d3roRadius.inner,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.subtle}`,
mb: 2,
opacity: currentStep === null || currentStep === step ? 1 : 0.5,
}}
>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
{step === 'llm'
? t('onboarding.llmModelMissing', { model: DEFAULT_LLM_MODEL })
: t('onboarding.sttModelMissing', { model: sttModelId })}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{step === 'llm' ? t('onboarding.llmModelSize') : t('onboarding.sttModelSize')}
</Typography>
</Box>
)
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={handleClose}
maxWidth="sm"
onClose={() => {}}
maxWidth="md"
fullWidth
disableEscapeKeyDown={isDownloading}
PaperProps={{
sx: {
borderRadius: d3roRadius.card,
backgroundColor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.subtle}`,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.default}`,
p: 1,
},
}}
>
@ -276,122 +230,398 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
...typoSx('heading'),
color: d3roPalette.text.primary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
pb: 1.5,
}}
>
{isSuccess ? (
<CheckCircle2 size={24} style={{ color: colorSuccess }} />
) : isFailed ? (
<AlertCircle size={24} style={{ color: colorDanger }} />
{phase === 'success' ? (
<CheckCircle2 size={24} style={{ color: d3roPalette.tag.green }} />
) : phase === 'failed' ? (
<AlertCircle size={24} style={{ color: d3roPalette.tag.red }} />
) : (
<CloudDownload size={24} style={{ color: colorAccent }} />
<Sparkles size={24} style={{ color: d3roPalette.accent.main }} />
)}
{t('onboarding.title')}
D3RO Voice
</DialogTitle>
<DialogContent sx={{ py: 3 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.subtitle')}
</Typography>
{/* ── 1. 사용 모드 선택 ── */}
{phase === 'select_mode' && (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2.5 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary }}>
D3RO Voice를 ? .
</Typography>
{(phase === 'prompt' || isDownloading) && neededSteps.map(renderStepInfo)}
<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: 800, fontSize: '15px', color: d3roPalette.text.primary }}>
AI
</Typography>
</Box>
<Chip label="100% 무료 / 강력 추천" size="small" sx={{ fontWeight: 700, 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>
{isDownloading && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{neededSteps.length > 1
? `${t('onboarding.step', {
current: String(stepIndex + 1),
total: String(neededSteps.length),
})} ${t('onboarding.downloading')}`
: t('onboarding.downloading')}
</Typography>
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
{busyStatus ? '…' : `${percent}%`}
</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: 700,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
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: 800, 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>
<LinearProgress
variant={busyStatus ? 'indeterminate' : 'determinate'}
value={percent}
sx={{ height: 8, borderRadius: d3roRadius.xs }}
/>
{detail && (
<Typography
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
{detail}
</Typography>
)}
{status && (
<Typography
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
{t('onboarding.status', { status: formatStatus(status) })}
</Typography>
)}
</Box>
)}
{isSuccess && (
<Typography sx={{ ...typoSx('body'), color: colorSuccess, mt: 2 }}>
{t('onboarding.success')}
</Typography>
{/* ── 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: 700, 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: 700 }}
>
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: 700, 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: 700, 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('gemma2:2b')}
disabled={!isOllamaConnected}
sx={{
fontWeight: 700,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{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: 700 }}
>
</Button>
</Box>
</Box>
)}
{isFailed && (
<Typography sx={{ ...typoSx('body'), color: colorDanger, mt: 2 }}>
{t('onboarding.failed', { message: errorMsg })}
</Typography>
{/* ── 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>
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
{phase === 'prompt' && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.cancel')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.download')}
</Button>
</>
)}
{isDownloading && (
<Button disabled sx={{ color: d3roPalette.text.disabled }}>
{t('onboarding.downloading')}
{phase === 'success' && (
<DialogActions sx={{ px: 3, pb: 2 }}>
<Button variant="contained" onClick={handleClose} fullWidth sx={{ py: 1.2, fontWeight: 700 }}>
D3RO Voice
</Button>
)}
{isSuccess && (
<Button
onClick={handleClose}
variant="contained"
sx={{ backgroundColor: colorSuccess }}
>
{t('onboarding.close')}
</Button>
)}
{isFailed && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.close')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.retry')}
</Button>
</>
)}
</DialogActions>
</DialogActions>
)}
</Dialog>
)
}