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
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:
parent
5cd1de6859
commit
708e20f747
406 changed files with 42464 additions and 6199 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue