// 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 ( {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('gemma2:2b') 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 ( Ollama 로컬 AI 설정 & 모델 관리 {/* ── 실시간 상태 바 ── */} {isConnected ? `Ollama 연결 완료 ${llmStatus?.serverVersion ? `(v${llmStatus.serverVersion})` : ''}` : 'Ollama 오프라인 (서버 실행 필요)'} 서버 URL: {llmStatus?.serverUrl || 'http://localhost:11434'} | 활성 모델: {activeModel || '—'} {!isConnected && ( )} {startMessage && ( {startMessage} )} {/* ── Step 1: Ollama 설치 및 기동 ── */} Ollama 설치 및 백그라운드 실행 Ollama는 PC 내에서 100% 무료, 오프라인으로 LLM을 구동하는 오픈소스 엔진입니다. {/* ── Step 2: 추천 모델 다운로드 ── */} 한국어 최적화 추천 모델 1-클릭 다운로드 설치된 모델: {installedModels.length}개 음성 인식 후 텍스트 다듬기, 번역, 요약에 가장 안정적인 경량 AI 모델들입니다. {/* 모델 카드 그리드 */} {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 ( {model.name} {model.recommended && ( )} {model.description} {/* 다운로드 진행률 또는 액션 버튼 */} {isDownloading ? ( {pullProgress.status || '다운로드 중...'} {pullProgress.percent}% ) : ( {isInstalled ? ( ) : ( )} )} ) })} {/* 터미널 명령어 수동 입력 안내 */} 직접 터미널에서 다운로드하려면 아래 명령어를 실행하세요: {/* ── Step 3: 실시간 연결 및 테스트 ── */} 동작 테스트 (AI 문장 정제) setTestPrompt(e.target.value)} placeholder="테스트할 음성 전사 문장 입력" sx={{ '& .MuiOutlinedInput-root': { fontSize: '12px', bgcolor: d3roPalette.bg.inset, }, }} /> {testResult && ( AI 정제 결과 ({activeModel}): {testResult} )} D3RO Voice는 인터넷 없이도 로컬에서 100% 프라이빗하게 작동합니다. ) }