diff --git a/src/main/ipc/window-handlers.ts b/src/main/ipc/window-handlers.ts index e59f32d..3e65547 100644 --- a/src/main/ipc/window-handlers.ts +++ b/src/main/ipc/window-handlers.ts @@ -1,6 +1,6 @@ // src/main/ipc/window-handlers.ts -import { ipcMain } from 'electron' +import { ipcMain, shell } from 'electron' import { IPC_CHANNELS } from '@shared/ipc-channels' import { ipcSuccess } from '@shared/errors' import { getMainWindow, hideResultPopup, hideRecordingTip } from '../windows/WindowManager' @@ -37,4 +37,10 @@ export function registerWindowHandlers(): void { ipcMain.on('window:hideRecordingTip', () => { hideRecordingTip() }) + + // 외부 URL 열기 + ipcMain.handle(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, async (_event, params: { url: string }) => { + await shell.openExternal(params.url) + return ipcSuccess(undefined) + }) } diff --git a/src/preload/index.ts b/src/preload/index.ts index f3ca195..2a840ae 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -230,7 +230,9 @@ const electronAPI = { getPlatform: () => invoke(IPC_CHANNELS.SYSTEM.GET_PLATFORM), getVersion: () => invoke(IPC_CHANNELS.SYSTEM.GET_VERSION), checkMicPermission: () => - invoke(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION) + invoke(IPC_CHANNELS.SYSTEM.CHECK_MIC_PERMISSION), + openExternal: (params: { url: string }) => + invoke(IPC_CHANNELS.SYSTEM.OPEN_EXTERNAL, params), }, // ── Instruction (커스텀 명령어) ──────────────────────── diff --git a/src/renderer/components/OllamaGuideModal.tsx b/src/renderer/components/OllamaGuideModal.tsx new file mode 100644 index 0000000..7f62960 --- /dev/null +++ b/src/renderer/components/OllamaGuideModal.tsx @@ -0,0 +1,161 @@ +// src/renderer/components/OllamaGuideModal.tsx +// Ollama 설치/설정 안내 모달 + +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Box, + Typography, + Button, + Divider, + IconButton, +} from '@mui/material' +import CloseIcon from '@mui/icons-material/Close' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' +import ContentCopyIcon from '@mui/icons-material/ContentCopy' +import { d3roPalette, d3roFontMono } from '../theme' +import { Led } from './ds' + +interface OllamaGuideModalProps { + open: boolean + onClose: () => void +} + +function CodeBlock({ children }: { children: string }): React.ReactElement { + return ( + + {children} + navigator.clipboard.writeText(children)} + sx={{ color: d3roPalette.text.inactive, '&:hover': { color: d3roPalette.accent.amber } }} + > + + + + ) +} + +export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): React.ReactElement { + const handleOpenLink = (url: string) => { + window.electronAPI.system.openExternal({ url }) + } + + return ( + + + OLLAMA SETUP GUIDE + + + + + + + + {/* Step 1 */} + + + + + STEP 1 — Ollama 설치 + + + + Ollama는 로컬에서 LLM을 실행하는 무료 도구입니다. + + + + + + + {/* Step 2 */} + + + + + STEP 2 — 모델 다운로드 + + + + 터미널에서 원하는 모델을 pull하세요. 한국어에 추천: + + + ollama pull qwen3:4b + + 또는 더 큰 모델: ollama pull qwen3:8b (더 정확, 더 느림) + + + + + + + {/* Step 3 */} + + + + + STEP 3 — 자동 연결 + + + + Ollama가 실행되면 D3RO-VOICE가 자동으로 감지합니다. + 하단 상태 바의 LED가 빨간색에서 초록색으로 바뀌면 준비 완료! + + + + + + + + + ) +} diff --git a/src/renderer/components/OnboardingModal.tsx b/src/renderer/components/OnboardingModal.tsx index 6db631c..75e06ae 100644 --- a/src/renderer/components/OnboardingModal.tsx +++ b/src/renderer/components/OnboardingModal.tsx @@ -14,6 +14,7 @@ import { import MicIcon from '@mui/icons-material/Mic' import KeyboardIcon from '@mui/icons-material/Keyboard' import CheckCircleIcon from '@mui/icons-material/CheckCircle' +import OpenInNewIcon from '@mui/icons-material/OpenInNew' import { d3roPalette, d3roFontMono } from '../theme' import { Led } from './ds' import { HotkeyRecordModal } from './HotkeyRecordModal' @@ -25,7 +26,7 @@ interface OnboardingModalProps { } export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { - const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: 완료 + const [step, setStep] = useState(0) // 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: 완료 const [devices, setDevices] = useState([]) const [selectedDevice, setSelectedDevice] = useState('default') const [hotkeyBinding, setHotkeyBinding] = useState(null) @@ -199,8 +200,45 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React. )} - {/* Step 3: 완료 */} + {/* Step 3: Ollama 설치 */} {step === 3 && ( + + + + + Ollama 설치 (선택) + + + + 번역, 요약 등 LLM 후처리를 사용하려면 Ollama가 필요합니다. + 음성 받아쓰기 자체는 Ollama 없이도 동작합니다. + + + + + $ ollama pull qwen3:4b + + + 설치 후 터미널에서 모델을 다운로드하세요 + + + + + + + + )} + + {/* Step 4: 완료 */} + {step === 4 && ( diff --git a/src/renderer/components/SettingsModal.tsx b/src/renderer/components/SettingsModal.tsx index 78adc4e..94bf087 100644 --- a/src/renderer/components/SettingsModal.tsx +++ b/src/renderer/components/SettingsModal.tsx @@ -687,10 +687,28 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac - + Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. 클라우드 의존성 없이 완전 로컬로 동작합니다. + + diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx index 7947d4e..cf0b26e 100644 --- a/src/renderer/components/StatusBar.tsx +++ b/src/renderer/components/StatusBar.tsx @@ -1,14 +1,17 @@ // src/renderer/components/StatusBar.tsx -// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 +// 인스트루먼트 섀시 하단 — 각인 스타일 상태 표시 + Ollama 오프라인 넛징 import { useState, useEffect } from 'react' -import { Box, Typography } from '@mui/material' +import { Box, Typography, Fade } from '@mui/material' import { Led } from './ds' import { d3roPalette, d3roFontMono } from '../theme' +import { OllamaGuideModal } from './OllamaGuideModal' import type { LLMStatus } from '@shared/types' export function StatusBar(): React.ReactElement { const [llmStatus, setLlmStatus] = useState(null) + const [showNudge, setShowNudge] = useState(false) + const [guideOpen, setGuideOpen] = useState(false) useEffect(() => { window.electronAPI.llm.getStatus().then((r) => { @@ -27,37 +30,115 @@ export function StatusBar(): React.ReactElement { const connected = llmStatus?.connectionState === 'connected' + // 오프라인 3초 후 넛징 버블 표시 + useEffect(() => { + if (!connected) { + const timer = setTimeout(() => setShowNudge(true), 3000) + return () => clearTimeout(timer) + } + setShowNudge(false) + return undefined + }, [connected]) + return ( - - - - - {connected ? 'OLLAMA' : 'OFFLINE'} + <> + + {/* 넛징 버블 */} + + + + Ollama가 실행되지 않고 있어요 + + + LLM 후처리(번역, 요약 등)를 사용하려면 Ollama가 필요합니다. + + { setGuideOpen(true); setShowNudge(false) }} + sx={{ + fontSize: '11px', + color: d3roPalette.accent.amber, + fontWeight: 700, + cursor: 'pointer', + '&:hover': { textDecoration: 'underline' }, + }} + > + 설치 안내 보기 → + + + + + + + !connected && setGuideOpen(true)} + sx={{ + fontFamily: d3roFontMono, + fontSize: '9px', + color: connected ? d3roPalette.text.inactive : d3roPalette.tag.red, + letterSpacing: '1px', + fontWeight: 700, + cursor: connected ? 'default' : 'pointer', + '&:hover': connected ? {} : { textDecoration: 'underline' }, + }} + > + {connected ? 'OLLAMA' : 'OFFLINE'} + + + + {llmStatus?.activeModel && ( + + {llmStatus.activeModel.toUpperCase()} + + )} + + + + + PRECISION DATA LINK - {llmStatus?.activeModel && ( - - {llmStatus.activeModel.toUpperCase()} - - )} - - - - - PRECISION DATA LINK - - + setGuideOpen(false)} /> + ) }