d3ro-voice/apps/desktop/src/renderer/components/OllamaGuideModal.tsx
2026-08-29 18:33:45 +09:00

683 lines
24 KiB
TypeScript

// src/renderer/components/OllamaGuideModal.tsx
// Ollama 설치/설정/모델 다운로드 종합 인터랙티브 모달 (v2 Midnight Glass)
import { useState, useEffect, useCallback } from 'react'
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Typography,
Button,
Divider,
IconButton,
Tooltip,
Paper,
Chip,
LinearProgress,
TextField,
CircularProgress,
Alert,
} from '@mui/material'
import {
X,
ExternalLink,
Copy,
Check,
RefreshCw,
Play,
Download,
CheckCircle2,
Zap,
} from 'lucide-react'
import {
d3roPalette,
d3roFontSans,
d3roFontMono,
d3roTypo,
d3roShadow,
d3roRadius,
typoSx,
} from '@d3ro/ui/theme'
import { Led } from '@d3ro/ui/components/ds'
import { useI18n } from '@d3ro/i18n'
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
export interface OllamaGuideModalProps {
open: boolean
onClose: () => void
}
interface RecommendedModel {
id: string
name: string
size: string
description: string
recommended?: boolean
}
const RECOMMENDED_MODELS: RecommendedModel[] = [
{
id: 'gemma2:2b',
name: 'Gemma 2 (2B)',
size: '1.6 GB',
description: '구글 경량 모델. 빠른 응답 속도와 저사양 PC에 최적화 (기본 추천)',
recommended: true,
},
{
id: 'llama3.2:3b',
name: 'Llama 3.2 (3B)',
size: '2.0 GB',
description: '메타의 최신 경량 모델. 텍스트 다듬기 및 문법 교정에 우수',
},
{
id: 'qwen2.5:3b',
name: 'Qwen 2.5 (3B)',
size: '1.9 GB',
description: '한국어 및 다국어 이해도가 매우 뛰어난 고성능 모델',
},
{
id: 'gemma2:9b',
name: 'Gemma 2 (9B)',
size: '5.4 GB',
description: '고성능 모델. 복잡한 요약 및 번역에 적합 (RAM 16GB+ 권장)',
},
]
function CodeBlock({ code }: { code: string }): React.ReactElement {
const { t } = useI18n()
const [copied, setCopied] = useState(false)
const handleCopy = useCallback(() => {
navigator.clipboard.writeText(code)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [code])
return (
<Box
sx={{
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
px: 2,
py: 1,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
boxShadow: d3roShadow.inset,
}}
>
<Typography
component="span"
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
color: d3roPalette.accent.light,
letterSpacing: '0.02em',
userSelect: 'all',
}}
>
{code}
</Typography>
<Tooltip title={copied ? t('common.copy') : t('common.copy')} placement="top">
<IconButton
size="small"
onClick={handleCopy}
sx={{
color: copied ? d3roPalette.tag.green : d3roPalette.text.inactive,
p: 0.5,
ml: 1,
'&:hover': {
color: copied ? d3roPalette.tag.green : d3roPalette.accent.light,
bgcolor: d3roPalette.glass.raised,
},
}}
>
{copied ? <Check size={14} /> : <Copy size={14} />}
</IconButton>
</Tooltip>
</Box>
)
}
export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement {
const { t } = useI18n()
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
const [installedModels, setInstalledModels] = useState<LLMModel[]>([])
const [activeModel, setActiveModel] = useState<string>('gemma2:2b')
const [checking, setChecking] = useState(false)
const [starting, setStarting] = useState(false)
const [startMessage, setStartMessage] = useState<string | null>(null)
// Download state
const [pullingModelId, setPullingModelId] = useState<string | null>(null)
const [pullProgress, setPullProgress] = useState<{
status: string
percent: number
completed: number
total: number
}>({ status: '', percent: 0, completed: 0, total: 0 })
// Test prompt state
const [testPrompt, setTestPrompt] = useState('안녕하세요 오늘의 날씨를 알려줘')
const [testResult, setTestResult] = useState<string | null>(null)
const [testing, setTesting] = useState(false)
const refreshStatus = useCallback(async () => {
setChecking(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) {
setLlmStatus(st.data)
}
const active = await window.electronAPI.llm.getActiveModel()
if (active.success && active.data) {
setActiveModel(active.data)
}
} finally {
setChecking(false)
}
}, [])
useEffect(() => {
if (!open) return
refreshStatus()
const unsubPull = window.electronAPI.llm.onPullProgress((e) => {
setPullingModelId(e.modelId)
setPullProgress({
status: e.status,
percent: e.percent,
completed: e.completed,
total: e.total,
})
if (e.percent >= 100 || e.status === 'success') {
setTimeout(() => {
setPullingModelId(null)
refreshStatus()
}, 1200)
}
})
const unsubStatus = window.electronAPI.llm.onStatusChanged((e) => {
setLlmStatus(e.status)
})
return () => {
unsubPull()
unsubStatus()
}
}, [open, refreshStatus])
const handleOpenLink = useCallback((url: string) => {
window.electronAPI.system.openExternal({ url })
}, [])
const handleStartOllama = useCallback(async () => {
setStarting(true)
setStartMessage(null)
try {
const res = await window.electronAPI.llm.startServer()
if (res.success) {
if (res.data === 'running') {
setStartMessage('Ollama가 이미 실행 중입니다.')
} else if (res.data === 'starting') {
setStartMessage('Ollama 서버를 시작했습니다. 연결 확인 중...')
setTimeout(() => refreshStatus(), 2000)
} else if (res.data === 'not-installed') {
setStartMessage('Ollama가 설치되어 있지 않습니다. 다운로드 버튼을 눌러 설치해 주세요.')
} else {
setStartMessage('Ollama 실행에 실패했습니다. 수동으로 Ollama를 실행해 주세요.')
}
}
} finally {
setStarting(false)
}
}, [refreshStatus])
const handlePullModel = useCallback(async (modelId: string) => {
setPullingModelId(modelId)
setPullProgress({ status: '다운로드 시작 중...', percent: 0, completed: 0, total: 0 })
try {
await window.electronAPI.llm.pullModel({ modelId })
} catch {
setPullingModelId(null)
}
}, [])
const handleSelectModel = useCallback(async (modelId: string) => {
await window.electronAPI.llm.setModel({ modelId })
await window.electronAPI.config.set({ key: 'llmModelId', value: modelId })
setActiveModel(modelId)
await refreshStatus()
}, [refreshStatus])
const handleRunTest = useCallback(async () => {
if (!testPrompt.trim()) return
setTesting(true)
setTestResult(null)
try {
const res = await window.electronAPI.llm.process({
text: testPrompt,
action: 'refine',
})
if (res.success && res.data) {
setTestResult(res.data.processedText)
} else {
setTestResult(`오류: ${res.error?.message ?? '응답 실패'}`)
}
} catch (err) {
setTestResult(`실패: ${err instanceof Error ? err.message : String(err)}`)
} finally {
setTesting(false)
}
}, [testPrompt])
const isConnected = llmStatus?.connectionState === 'connected'
return (
<Dialog
open={open}
onClose={onClose}
maxWidth="md"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.card,
backgroundImage: 'none',
borderRadius: d3roRadius.card,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
boxShadow: d3roShadow.dialog,
overflow: 'hidden',
},
}}
>
<DialogTitle
sx={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
py: 2,
px: 3,
borderBottom: `1px solid ${d3roPalette.glass.hairline}`,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isConnected ? 'green' : 'amber'} pulse={!isConnected} size={9} />
<Typography
sx={{
fontFamily: d3roFontSans,
fontWeight: 500,
fontSize: d3roTypo.heading.size,
color: d3roPalette.text.primary,
}}
>
Ollama AI &
</Typography>
</Box>
<IconButton
onClick={onClose}
size="small"
sx={{
color: d3roPalette.text.inactive,
'&:hover': { color: d3roPalette.text.primary, bgcolor: d3roPalette.glass.raised },
}}
>
<X size={18} />
</IconButton>
</DialogTitle>
<DialogContent sx={{ px: 3, py: 2.5, display: 'flex', flexDirection: 'column', gap: 2.5 }}>
{/* ── 실시간 상태 바 ── */}
<Paper
elevation={0}
sx={{
p: 2,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${isConnected ? d3roPalette.tag.green : d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 1.5,
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Led color={isConnected ? 'green' : 'red'} size={8} />
<Box>
<Typography sx={{ ...typoSx('heading'), fontSize: '13px', color: isConnected ? d3roPalette.tag.green : d3roPalette.tag.red }}>
{isConnected
? `Ollama 연결 완료 ${llmStatus?.serverVersion ? `(v${llmStatus.serverVersion})` : ''}`
: 'Ollama 오프라인 (서버 실행 필요)'}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
URL: {llmStatus?.serverUrl || 'http://localhost:11434'} | : {activeModel || '—'}
</Typography>
</Box>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
variant="outlined"
startIcon={<RefreshCw size={13} className={checking ? 'animate-spin' : ''} />}
onClick={refreshStatus}
disabled={checking}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
borderColor: d3roPalette.glass.hairlineStrong,
color: d3roPalette.text.primary,
}}
>
</Button>
{!isConnected && (
<Button
size="small"
variant="contained"
startIcon={<Play size={13} />}
onClick={handleStartOllama}
disabled={starting}
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
fontWeight: 500,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{starting ? '실행 중...' : 'Ollama 자동 실행'}
</Button>
)}
</Box>
</Paper>
{startMessage && (
<Alert severity={isConnected ? 'success' : 'info'} sx={{ py: 0.5 }}>
{startMessage}
</Alert>
)}
{/* ── Step 1: Ollama 설치 및 기동 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 1" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
Ollama
</Typography>
</Box>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, pl: 1 }}>
Ollama는 PC 100% , LLM을 .
</Typography>
<Box sx={{ pl: 1, display: 'flex', gap: 1.5, flexWrap: 'wrap', pt: 0.5 }}>
<Button
variant="outlined"
size="small"
endIcon={<ExternalLink size={14} />}
onClick={() => handleOpenLink('https://ollama.com/download')}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
borderColor: d3roPalette.accent.main,
color: d3roPalette.accent.light,
'&:hover': {
borderColor: d3roPalette.accent.main,
bgcolor: d3roPalette.glass.raised,
},
}}
>
1. Ollama (ollama.com/download)
</Button>
<Button
variant="outlined"
size="small"
startIcon={<Play size={14} />}
onClick={handleStartOllama}
disabled={starting}
sx={{
fontFamily: d3roFontMono,
fontSize: d3roTypo.small.size,
borderColor: d3roPalette.glass.hairlineStrong,
color: d3roPalette.text.primary,
}}
>
2.
</Button>
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.glass.hairline }} />
{/* ── Step 2: 추천 모델 다운로드 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 2" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
1-
</Typography>
</Box>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel, fontFamily: d3roFontMono }}>
: {installedModels.length}
</Typography>
</Box>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, pl: 1 }}>
, , AI .
</Typography>
{/* 모델 카드 그리드 */}
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 1.5, pl: 1 }}>
{RECOMMENDED_MODELS.map((model) => {
const isInstalled = installedModels.some((m) => m.name === model.id || m.name.startsWith(model.id.split(':')[0]))
const isActive = activeModel === model.id || activeModel.startsWith(model.id.split(':')[0])
const isDownloading = pullingModelId === model.id
return (
<Paper
key={model.id}
elevation={0}
sx={{
p: 1.75,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${isActive ? d3roPalette.accent.main : d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
gap: 1,
}}
>
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography sx={{ fontWeight: 500, fontSize: '13px', color: d3roPalette.text.primary }}>
{model.name}
</Typography>
{model.recommended && (
<Chip label="추천" size="small" sx={{ height: 18, fontSize: '9px', fontWeight: 500, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
)}
</Box>
<Chip label={model.size} size="small" sx={{ height: 18, fontSize: '10px', fontFamily: d3roFontMono }} />
</Box>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.secondary, lineHeight: 1.4 }}>
{model.description}
</Typography>
</Box>
{/* 다운로드 진행률 또는 액션 버튼 */}
{isDownloading ? (
<Box sx={{ mt: 1 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 0.5 }}>
<Typography sx={{ fontSize: '10px', color: d3roPalette.accent.light }}>
{pullProgress.status || '다운로드 중...'}
</Typography>
<Typography sx={{ fontSize: '10px', fontFamily: d3roFontMono, color: d3roPalette.text.primary }}>
{pullProgress.percent}%
</Typography>
</Box>
<LinearProgress variant="determinate" value={pullProgress.percent} sx={{ height: 4, borderRadius: 2 }} />
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 1, mt: 0.5 }}>
{isInstalled ? (
<Button
size="small"
variant={isActive ? 'contained' : 'outlined'}
onClick={() => handleSelectModel(model.id)}
startIcon={isActive ? <CheckCircle2 size={13} /> : undefined}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
py: 0.25,
bgcolor: isActive ? d3roPalette.tag.green : 'transparent',
borderColor: isActive ? d3roPalette.tag.green : d3roPalette.glass.hairlineStrong,
color: isActive ? d3roPalette.bg.app : d3roPalette.text.primary,
'&:hover': {
bgcolor: isActive ? d3roPalette.tag.green : d3roPalette.glass.raised,
},
}}
>
{isActive ? '사용 중' : '기본으로 선택'}
</Button>
) : (
<Button
size="small"
variant="contained"
startIcon={<Download size={13} />}
onClick={() => handlePullModel(model.id)}
disabled={!isConnected || Boolean(pullingModelId)}
sx={{
fontFamily: d3roFontSans,
fontSize: '11px',
fontWeight: 500,
py: 0.25,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
(Pull)
</Button>
)}
</Box>
)}
</Paper>
)
})}
</Box>
{/* 터미널 명령어 수동 입력 안내 */}
<Box sx={{ pl: 1, display: 'flex', flexDirection: 'column', gap: 1, pt: 1 }}>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}>
:
</Typography>
<CodeBlock code="ollama pull gemma2:2b" />
</Box>
</Box>
<Divider sx={{ borderColor: d3roPalette.glass.hairline }} />
{/* ── Step 3: 실시간 연결 및 테스트 ── */}
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Chip label="STEP 3" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.tag.greenBg, color: d3roPalette.tag.green }} />
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
(AI )
</Typography>
</Box>
<Box sx={{ pl: 1, display: 'flex', gap: 1 }}>
<TextField
size="small"
fullWidth
value={testPrompt}
onChange={(e) => setTestPrompt(e.target.value)}
placeholder="테스트할 음성 전사 문장 입력"
sx={{
'& .MuiOutlinedInput-root': {
fontSize: '12px',
bgcolor: d3roPalette.bg.inset,
},
}}
/>
<Button
variant="contained"
size="small"
onClick={handleRunTest}
disabled={!isConnected || testing}
startIcon={testing ? <CircularProgress size={12} color="inherit" /> : <Zap size={14} />}
sx={{
fontFamily: d3roFontMono,
fontSize: '11px',
fontWeight: 500,
minWidth: 100,
bgcolor: d3roPalette.accent.main,
color: d3roPalette.bg.app,
'&:hover': { bgcolor: d3roPalette.accent.hover },
}}
>
{testing ? '처리 중' : '테스트'}
</Button>
</Box>
{testResult && (
<Paper
elevation={0}
sx={{
ml: 1,
p: 1.5,
bgcolor: d3roPalette.bg.inset,
border: `1px solid ${d3roPalette.glass.hairlineStrong}`,
borderRadius: d3roRadius.small,
}}
>
<Typography sx={{ fontSize: '11px', color: d3roPalette.accent.light, fontWeight: 500, mb: 0.5 }}>
AI ({activeModel}):
</Typography>
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
{testResult}
</Typography>
</Paper>
)}
</Box>
</DialogContent>
<DialogActions
sx={{
px: 3,
py: 2,
borderTop: `1px solid ${d3roPalette.glass.hairline}`,
display: 'flex',
justifyContent: 'space-between',
}}
>
<Typography sx={{ fontSize: '11px', color: d3roPalette.text.dimLabel }}>
D3RO Voice는 100% .
</Typography>
<Button
onClick={onClose}
variant="contained"
sx={{
fontFamily: d3roFontSans,
fontSize: d3roTypo.small.size,
fontWeight: 600,
borderRadius: d3roRadius.button,
}}
>
{t('common.confirm')}
</Button>
</DialogActions>
</Dialog>
)
}