// 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: 'gemma4:e4b',
name: 'Gemma 4 (E4B)',
size: '약 4 GB',
description: '이 앱의 기본 로컬 모델. 추론 토큰을 쓰지 않아 받아쓰기 다듬기에 가장 빠름 (기본 추천)',
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: 'phi4',
name: 'Phi 4',
size: '9.1 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 (
{code}
{copied ? : }
)
}
export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement {
const { t } = useI18n()
const [llmStatus, setLlmStatus] = useState(null)
const [installedModels, setInstalledModels] = useState([])
const [activeModel, setActiveModel] = useState('gemma4:e4b')
const [checking, setChecking] = useState(false)
const [starting, setStarting] = useState(false)
const [startMessage, setStartMessage] = useState(null)
// Download state
const [pullingModelId, setPullingModelId] = useState(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(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 (
)
}