683 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|