From aa65e710ec71b1c24d7e776d10aad67ea1fa04e7 Mon Sep 17 00:00:00 2001 From: yunchan8804 Date: Wed, 15 Apr 2026 19:47:27 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=B0=B0=ED=8F=AC=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=E2=80=94=20Ollama/sidecar=20?= =?UTF-8?q?=EB=B2=88=EB=93=A4=20+=20NSIS=20=EC=9E=90=EB=8F=99=20VC++=20+?= =?UTF-8?q?=20GitLab=20CI=20+=20=EC=98=A8=EB=B3=B4=EB=94=A9=20=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sidecar 슬림화: torch/pyannote 제거, ctranslate2 GPU 감지, /diarize 삭제 - Ollama 번들: resources/ollama/에 포터블 바이너리 배치, LocalLLMService 1순위 탐색 - installer.nsh: VC++ 재배포 x64 자동 다운로드(aka.ms 경유) + 사일런트 설치 - electron-builder: extraResources에 ollama 추가, nsis.include로 installer.nsh 연결 - scripts: download-ollama.ps1/sh 신규 - LLM.PULL_MODEL IPC 핸들러 + LocalLLMService.pullModel() 구현 (api/pull 스트리밍) - 온보딩 모달: gemma4:e4b 미설치 감지 시 자동 표시, 진행률 UI, i18n(ko/en) 키 추가 - .gitlab-ci.yml: Windows 러너에서 sidecar/sox/ollama 준비 후 NSIS 패키징, 태그 시 Release 자동 생성 --- .gitignore | 5 + .gitlab-ci.yml | 107 ++-- apps/desktop/build/installer.nsh | 60 ++ apps/desktop/electron-builder.yml | 28 +- apps/desktop/scripts/download-ollama.ps1 | 69 +++ apps/desktop/scripts/download-ollama.sh | 61 ++ apps/desktop/sidecar/main.py | 169 +----- apps/desktop/sidecar/requirements.txt | 2 - apps/desktop/src/main/ipc/llm-handlers.ts | 15 + .../src/main/services/LocalLLMService.ts | 78 +++ apps/desktop/src/main/utils/paths.ts | 14 + apps/desktop/src/preload/index.ts | 12 + .../renderer/components/OnboardingModal.tsx | 537 ++++++++---------- memory/project_status.md | 36 +- packages/i18n/src/locales/en.json | 16 +- packages/i18n/src/locales/ko.json | 16 +- 16 files changed, 725 insertions(+), 500 deletions(-) create mode 100644 apps/desktop/build/installer.nsh create mode 100644 apps/desktop/scripts/download-ollama.ps1 create mode 100644 apps/desktop/scripts/download-ollama.sh diff --git a/.gitignore b/.gitignore index 42f6d45..1d7601f 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,11 @@ sidecar-dist/ # Bundled binaries (download via scripts) resources/sox/*.exe resources/sox/*.dll +apps/desktop/resources/ollama/ +apps/desktop/resources/sox/*.exe +apps/desktop/resources/sox/*.dll +apps/desktop/build/installer.nsh +!apps/desktop/build/installer.nsh # IDE .idea/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5f9c00d..b73b2bd 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,14 +1,17 @@ stages: - check - test + - prepare - build + - package - release variables: NODE_VERSION: "20" + PYTHON_VERSION: "3.11" + OLLAMA_VERSION: "v0.5.7" npm_config_cache: "$CI_PROJECT_DIR/.npm" -# Node.js 캐시 .node-cache: &node-cache cache: key: @@ -18,8 +21,9 @@ variables: - .npm/ - node_modules/ -# ── Check Stage ────────────────────────────────────────── - +# ──────────────────────────────────────────────────────────────────── +# Check +# ──────────────────────────────────────────────────────────────────── lint: stage: check image: node:${NODE_VERSION} @@ -31,6 +35,7 @@ lint: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_TAG typecheck: stage: check @@ -43,9 +48,11 @@ typecheck: rules: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + - if: $CI_COMMIT_TAG -# ── Test Stage ─────────────────────────────────────────── - +# ──────────────────────────────────────────────────────────────────── +# Test +# ──────────────────────────────────────────────────────────────────── unit-test: stage: test image: node:${NODE_VERSION} @@ -58,42 +65,74 @@ unit-test: - if: $CI_PIPELINE_SOURCE == "merge_request_event" - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH -# ── Build Stage ────────────────────────────────────────── - -build: - stage: build - image: node:${NODE_VERSION} - <<: *node-cache - before_script: - - npm ci - script: - - npm run build - artifacts: - paths: - - out/ - expire_in: 1 day - rules: - - if: $CI_PIPELINE_SOURCE == "merge_request_event" - - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH - -# ── Release Stage (태그 푸시 시만 실행) ────────────────── - +# ──────────────────────────────────────────────────────────────────── +# Package Windows (태그 푸시 또는 수동 트리거 시만 실행) +# - PyInstaller sidecar 빌드 → SoX/Ollama 다운로드 → electron-builder --win +# - Windows self-hosted runner 필요 (native 모듈 + PyInstaller 네이티브 빌드) +# ──────────────────────────────────────────────────────────────────── package-windows: - stage: release + stage: package tags: - - windows # Windows 러너 필요 (native 모듈 빌드) + - windows + - shell # self-hosted Windows runner (shell executor) + variables: + CI_RUNNER_PROTECTED: "false" before_script: + # Node 20 + Python 3.11이 러너에 사전 설치되어 있어야 함 + - node --version + - python --version - npm ci script: - - npm run build - - npm run dist + # 1) Python sidecar 의존성 설치 + PyInstaller 빌드 + - python -m pip install --upgrade pip + - python -m pip install pyinstaller + - python -m pip install -r apps/desktop/sidecar/requirements.txt + - python apps/desktop/scripts/build-sidecar.py + # 2) SoX 다운로드 + - powershell -ExecutionPolicy Bypass -File apps/desktop/scripts/download-sox.ps1 + # 3) Ollama 다운로드 + - powershell -ExecutionPolicy Bypass -File apps/desktop/scripts/download-ollama.ps1 + # 4) electron-vite 빌드 + electron-builder NSIS 패키징 + - npm run build --workspace=@d3ro/desktop + - npm run dist --workspace=@d3ro/desktop artifacts: + name: "d3ro-voice-windows-${CI_COMMIT_SHORT_SHA}" paths: - - release/ - expire_in: 30 days + - apps/desktop/release/ + expire_in: 90 days + expose_as: "Windows Installer" rules: - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/ + - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH + when: manual + allow_failure: true -# Docker 러너에서 Windows 빌드가 불가한 경우 아래 대안 사용: -# Wine + electron-builder --linux 크로스빌드 또는 -# Windows self-hosted runner 등록 +# ──────────────────────────────────────────────────────────────────── +# Release (태그 시 GitLab Releases 페이지에 자동 첨부) +# ──────────────────────────────────────────────────────────────────── +release-create: + stage: release + image: registry.gitlab.com/gitlab-org/release-cli:latest + needs: + - job: package-windows + artifacts: true + script: + - echo "Creating GitLab Release for tag $CI_COMMIT_TAG" + release: + name: "D3RO Voice $CI_COMMIT_TAG" + tag_name: "$CI_COMMIT_TAG" + description: | + ## D3RO Voice $CI_COMMIT_TAG + + Windows 설치 파일은 **Job artifacts**에서 다운로드하거나 아래 링크에서 받을 수 있습니다. + + - 기본 LLM 모델: `gemma4:e4b` (첫 실행 시 자동 다운로드, ~9.6GB) + - Whisper 모델: `large-v3` (첫 실행 시 자동 다운로드, ~3GB) + - VC++ 재배포 패키지 선행 설치 필요 (설치 마법사에서 안내) + assets: + links: + - name: "Windows Installer (artifacts)" + url: "$CI_PROJECT_URL/-/jobs/artifacts/$CI_COMMIT_TAG/browse/apps/desktop/release?job=package-windows" + link_type: package + rules: + - if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/ diff --git a/apps/desktop/build/installer.nsh b/apps/desktop/build/installer.nsh new file mode 100644 index 0000000..d076b78 --- /dev/null +++ b/apps/desktop/build/installer.nsh @@ -0,0 +1,60 @@ +; build/installer.nsh +; NSIS 커스텀 스크립트. electron-builder의 nsis.include로 참조됨. +; +; 목적: +; 1) Visual C++ 재배포 패키지(x64, 2015-2022) 확인 및 사일런트 자동 설치 +; - Ollama 및 faster-whisper 사이드카의 네이티브 의존성이 요구함. +; - NSIS 내장 NSISdl 플러그인(인터넷 다운로드 표준)으로 aka.ms 경유 다운로드. +; 2) 언인스톨 시 번들 Ollama 프로세스 종료. + +!macro customInstall + DetailPrint "VC++ 재배포 패키지(x64) 확인 중..." + + ; x64 Visual C++ 2015-2022 재배포 패키지 설치 여부 확인 + ClearErrors + ReadRegDWORD $0 HKLM "SOFTWARE\Microsoft\VisualStudio\14.0\VC\Runtimes\x64" "Installed" + + ${If} $0 != "1" + DetailPrint "VC++ 재배포 미설치 → 자동 다운로드/설치 시도" + + ; aka.ms/vs/17/release/vc_redist.x64.exe (영구 리다이렉트 → 최신 17.x) + ; NSISdl은 리다이렉트를 따라가며 Microsoft 공식 MSI 패키지를 받는다. + NSISdl::download /TIMEOUT=30000 "https://aka.ms/vs/17/release/vc_redist.x64.exe" "$TEMP\vc_redist.x64.exe" + Pop $R0 + + ${If} $R0 == "success" + DetailPrint "vc_redist.x64.exe 사일런트 설치 실행" + ExecWait '"$TEMP\vc_redist.x64.exe" /install /quiet /norestart' $1 + Delete "$TEMP\vc_redist.x64.exe" + + ${If} $1 == 0 + DetailPrint "VC++ 재배포 설치 완료" + ${ElseIf} $1 == 1638 + ; 1638 = 동일/상위 버전 이미 설치됨 + DetailPrint "VC++ 재배포 동일 이상 버전 이미 설치됨 (코드 1638)" + ${ElseIf} $1 == 3010 + ; 3010 = 설치 성공, 재부팅 필요 + DetailPrint "VC++ 재배포 설치 완료 (재부팅 필요)" + ${Else} + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "VC++ 재배포 설치 중 경고 발생 (코드 $1).$\r$\n일부 기능이 동작하지 않을 수 있습니다.$\r$\n수동 설치: https://aka.ms/vs/17/release/vc_redist.x64.exe" + ${EndIf} + ${Else} + DetailPrint "VC++ 재배포 다운로드 실패: $R0" + MessageBox MB_OK|MB_ICONEXCLAMATION \ + "VC++ 재배포 패키지 다운로드에 실패했습니다.$\r$\n$\r$\n아래 링크에서 수동으로 설치해주세요:$\r$\nhttps://aka.ms/vs/17/release/vc_redist.x64.exe$\r$\n$\r$\n설치하지 않으면 음성 인식이 동작하지 않습니다." + ${EndIf} + ${Else} + DetailPrint "VC++ 재배포 이미 설치됨." + ${EndIf} + + ${If} ${FileExists} "$INSTDIR\resources\ollama\ollama.exe" + DetailPrint "번들 Ollama 발견: $INSTDIR\resources\ollama\ollama.exe" + ${EndIf} +!macroend + +!macro customUnInstall + DetailPrint "번들 Ollama 프로세스 종료 시도" + nsExec::Exec 'taskkill /F /IM ollama.exe' + Pop $0 +!macroend diff --git a/apps/desktop/electron-builder.yml b/apps/desktop/electron-builder.yml index ca7c520..506169a 100644 --- a/apps/desktop/electron-builder.yml +++ b/apps/desktop/electron-builder.yml @@ -26,15 +26,6 @@ win: - x64 icon: resources/icons/icon.ico -nsis: - oneClick: false - perMachine: false - allowToChangeInstallationDirectory: true - createDesktopShortcut: true - createStartMenuShortcut: true - shortcutName: D3RO Voice - deleteAppDataOnUninstall: false - # ──────────────────────────────────────────────────────────────────── # macOS # ──────────────────────────────────────────────────────────────────── @@ -111,4 +102,23 @@ extraResources: filter: - "**/*" + # Ollama 바이너리 + # 빌드 전: powershell scripts/download-ollama.ps1 (Windows) + # bash scripts/download-ollama.sh (macOS/Linux) + # 번들된 ollama가 있으면 LocalLLMService가 1순위로 사용. + - from: resources/ollama/ + to: ollama/ + filter: + - "**/*" + +nsis: + oneClick: false + perMachine: false + allowToChangeInstallationDirectory: true + createDesktopShortcut: true + createStartMenuShortcut: true + shortcutName: D3RO Voice + deleteAppDataOnUninstall: false + include: build/installer.nsh + npmRebuild: true diff --git a/apps/desktop/scripts/download-ollama.ps1 b/apps/desktop/scripts/download-ollama.ps1 new file mode 100644 index 0000000..3abeae6 --- /dev/null +++ b/apps/desktop/scripts/download-ollama.ps1 @@ -0,0 +1,69 @@ +# scripts/download-ollama.ps1 +# Ollama Windows 바이너리(포터블)를 resources/ollama/에 배치한다. +# 실행: powershell -ExecutionPolicy Bypass -File scripts/download-ollama.ps1 +# +# 주: Ollama 공식은 NSIS 설치러너만 배포하므로, 여기서는 압축 형식으로 배포되는 +# ollama-windows-amd64.zip(release asset)을 GitHub에서 받는다. + +$ErrorActionPreference = "Stop" + +$OLLAMA_VERSION = if ($env:OLLAMA_VERSION) { $env:OLLAMA_VERSION } else { "v0.5.7" } +$OLLAMA_URL = "https://github.com/ollama/ollama/releases/download/$OLLAMA_VERSION/ollama-windows-amd64.zip" +$DEST_DIR = Join-Path $PSScriptRoot "..\resources\ollama" +$TEMP_ZIP = Join-Path $env:TEMP "ollama-$OLLAMA_VERSION-windows-amd64.zip" +$TEMP_DIR = Join-Path $env:TEMP "ollama-extract" + +Write-Host "=== Ollama $OLLAMA_VERSION Windows 바이너리 다운로드 ===" -ForegroundColor Cyan + +if (Test-Path (Join-Path $DEST_DIR "ollama.exe")) { + Write-Host "Ollama가 이미 존재합니다: $DEST_DIR\ollama.exe" -ForegroundColor Green + exit 0 +} + +if (Test-Path $TEMP_ZIP) { Remove-Item -Force $TEMP_ZIP } + +Write-Host "다운로드 중: $OLLAMA_URL" +try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + Invoke-WebRequest -Uri $OLLAMA_URL -OutFile $TEMP_ZIP -UseBasicParsing +} catch { + Write-Host "자동 다운로드 실패: $_" -ForegroundColor Red + Write-Host "" + Write-Host "=== 수동 설치 방법 ===" -ForegroundColor Yellow + Write-Host "https://github.com/ollama/ollama/releases 에서 ollama-windows-amd64.zip 다운로드 후" + Write-Host "압축 해제해 ollama.exe를 아래 경로로 복사:" -ForegroundColor White + Write-Host " $DEST_DIR" + exit 1 +} + +$fileSize = (Get-Item $TEMP_ZIP).Length +if ($fileSize -lt 1000000) { + Write-Host "다운로드된 파일이 너무 작습니다 (${fileSize} bytes)." -ForegroundColor Red + exit 1 +} + +Write-Host "다운로드 완료 ($([math]::Round($fileSize / 1MB, 1)) MB)" +Write-Host "압축 해제 중..." + +if (Test-Path $TEMP_DIR) { Remove-Item -Recurse -Force $TEMP_DIR } +Expand-Archive -Path $TEMP_ZIP -DestinationPath $TEMP_DIR -Force + +New-Item -ItemType Directory -Force -Path $DEST_DIR | Out-Null + +# zip 구조: ollama.exe + lib/ (DLL 등). 모두 복사. +Get-ChildItem -Path $TEMP_DIR -Force | ForEach-Object { + Copy-Item $_.FullName -Destination $DEST_DIR -Recurse -Force + Write-Host " 복사: $($_.Name)" -ForegroundColor Green +} + +Remove-Item -Recurse -Force $TEMP_DIR -ErrorAction SilentlyContinue +Remove-Item -Force $TEMP_ZIP -ErrorAction SilentlyContinue + +if (Test-Path (Join-Path $DEST_DIR "ollama.exe")) { + $ollamaSize = (Get-Item (Join-Path $DEST_DIR "ollama.exe")).Length + Write-Host "" + Write-Host "Ollama 설치 완료: $DEST_DIR ($([math]::Round($ollamaSize / 1MB, 1)) MB)" -ForegroundColor Green +} else { + Write-Host "ollama.exe를 찾을 수 없습니다. zip 구조를 확인하세요." -ForegroundColor Red + exit 1 +} diff --git a/apps/desktop/scripts/download-ollama.sh b/apps/desktop/scripts/download-ollama.sh new file mode 100644 index 0000000..8c863f3 --- /dev/null +++ b/apps/desktop/scripts/download-ollama.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# scripts/download-ollama.sh +# Ollama 포터블 바이너리를 resources/ollama/에 배치한다. (macOS/Linux) +# 실행: bash scripts/download-ollama.sh + +set -euo pipefail + +OLLAMA_VERSION="${OLLAMA_VERSION:-v0.5.7}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +DEST_DIR="$SCRIPT_DIR/../resources/ollama" + +OS="$(uname -s)" +ARCH="$(uname -m)" + +case "$OS" in + Darwin) + # macOS는 .tgz 형식 (ollama-darwin.tgz) + ASSET="ollama-darwin.tgz" + ;; + Linux) + if [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then + ASSET="ollama-linux-arm64.tgz" + else + ASSET="ollama-linux-amd64.tgz" + fi + ;; + *) + echo "지원하지 않는 OS: $OS" >&2 + exit 1 + ;; +esac + +URL="https://github.com/ollama/ollama/releases/download/${OLLAMA_VERSION}/${ASSET}" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +mkdir -p "$DEST_DIR" + +if [ -f "$DEST_DIR/ollama" ]; then + echo "Ollama가 이미 존재합니다: $DEST_DIR/ollama" + exit 0 +fi + +echo "=== Ollama ${OLLAMA_VERSION} ${OS} ${ARCH} 다운로드 ===" +echo "URL: $URL" + +curl -L --fail --retry 3 -o "$TMP/$ASSET" "$URL" +tar -xzf "$TMP/$ASSET" -C "$TMP" + +# tgz 구조: bin/ollama + lib/ (ROCm/CUDA 등). 필요한 것만 복사. +if [ -f "$TMP/bin/ollama" ]; then + cp "$TMP/bin/ollama" "$DEST_DIR/ollama" + chmod +x "$DEST_DIR/ollama" +fi + +# 라이브러리 동봉(CUDA/ROCm 런타임용) +if [ -d "$TMP/lib" ]; then + cp -R "$TMP/lib" "$DEST_DIR/" +fi + +echo "Ollama 설치 완료: $DEST_DIR" diff --git a/apps/desktop/sidecar/main.py b/apps/desktop/sidecar/main.py index 8d9fa41..688492f 100644 --- a/apps/desktop/sidecar/main.py +++ b/apps/desktop/sidecar/main.py @@ -1,6 +1,6 @@ """ D3RO-VOICE STT Sidecar (FastAPI HTTP 서버) -faster-whisper를 사용한 로컬 음성 인식 서비스. +faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포. 사용법: python main.py --port 18765 @@ -10,6 +10,9 @@ faster-whisper를 사용한 로컬 음성 인식 서비스. POST /load - Whisper 모델 로딩 POST /transcribe - 오디오 전사 (multipart) POST /shutdown - 서버 종료 + +주: 화자 구분(diarization)은 Phase 15.5에서 LLM 추정 경로가 primary이며, +pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정. """ from __future__ import annotations @@ -61,20 +64,24 @@ app = FastAPI(title="D3RO-VOICE STT Sidecar", lifespan=lifespan) def _detect_gpu() -> None: - """GPU(CUDA) 사용 가능 여부를 감지한다.""" + """GPU(CUDA) 사용 가능 여부를 ctranslate2로 감지한다. + + torch 의존 제거를 위해 ctranslate2의 네이티브 CUDA 감지를 사용한다. + ctranslate2는 faster-whisper의 백엔드이므로 항상 함께 설치된다. + """ global _gpu_available try: - import torch + import ctranslate2 - _gpu_available = torch.cuda.is_available() + cuda_count = ctranslate2.get_cuda_device_count() + _gpu_available = cuda_count > 0 if _gpu_available: - device_name = torch.cuda.get_device_name(0) - logger.info("GPU 감지: %s", device_name) + logger.info("GPU 감지: CUDA 디바이스 %d개", cuda_count) else: logger.info("GPU 미감지, CPU 모드로 동작") - except ImportError: + except Exception as exc: _gpu_available = False - logger.info("PyTorch 미설치, CPU 모드로 동작") + logger.info("GPU 감지 실패, CPU 모드로 동작: %s", exc) # ── 엔드포인트 ───────────────────────────────────────────── @@ -97,14 +104,14 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 """Whisper 모델을 로딩한다. Request body: - { "model_id": "base" } -- tiny, base, small, medium, large-v3 + { "model_id": "large-v3" } -- tiny, base, small, medium, large-v3 Returns: - { "status": "loaded", "model_id": "base", "load_time_ms": 1234 } + { "status": "loaded", "model_id": "large-v3", "load_time_ms": 1234 } """ global _model, _model_id - model_id: str = body.get("model_id", "base") + model_id: str = body.get("model_id", "large-v3") logger.info("모델 로딩 시작: %s", model_id) start_time = time.monotonic() @@ -115,10 +122,6 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001 device = "cuda" if _gpu_available else "cpu" compute_type = "float16" if _gpu_available else "int8" - # 모델 크기별 compute_type 조정 - if model_id in ("large-v3", "medium") and not _gpu_available: - compute_type = "int8" - _model = WhisperModel( model_id, device=device, @@ -165,15 +168,6 @@ async def transcribe( language - 언어 코드 ('auto', 'ko', 'en', ...) vad_filter - VAD 필터 활성화 ('true' / 'false') initial_prompt - 초기 프롬프트 (컨텍스트 힌트) - - Returns: - { - "text": "전사된 텍스트", - "segments": [...], - "language": "ko", - "duration": 3.5, - "processing_time": 1234 - } """ if _model is None: return JSONResponse( @@ -184,7 +178,6 @@ async def transcribe( start_time = time.monotonic() try: - # PCM16 바이너리 읽기 pcm_bytes = await audio.read() if len(pcm_bytes) == 0: @@ -193,12 +186,10 @@ async def transcribe( content={"status": "error", "message": "오디오 데이터가 비어있습니다"}, ) - # PCM16 → float32 변환 (-1.0 ~ 1.0) audio_array = ( np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0 ) - # 오디오 길이 계산 (16kHz mono 기준) sample_rate = 16000 audio_duration = len(audio_array) / sample_rate @@ -209,7 +200,6 @@ async def transcribe( vad_filter, ) - # 전사 옵션 구성 transcribe_kwargs: dict = { "vad_filter": vad_filter.lower() == "true", "beam_size": 5, @@ -221,7 +211,6 @@ async def transcribe( if initial_prompt: transcribe_kwargs["initial_prompt"] = initial_prompt - # faster-whisper 전사 실행 # VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도 try: segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs) @@ -233,7 +222,6 @@ async def transcribe( else: raise - # 세그먼트 수집 segments_list: list[dict] = [] full_text_parts: list[str] = [] @@ -278,123 +266,6 @@ async def transcribe( ) -# ── 화자 구분 (Phase 15.5) ──────────────────────────────── - -_diarization_pipeline = None - - -def _get_diarization_pipeline(hf_token: str): - """pyannote speaker diarization 파이프라인을 로드한다 (캐시).""" - global _diarization_pipeline - if _diarization_pipeline is not None: - return _diarization_pipeline - - try: - from pyannote.audio import Pipeline - - logger.info("Diarization 파이프라인 로딩 시작") - _diarization_pipeline = Pipeline.from_pretrained( - "pyannote/speaker-diarization-3.1", - use_auth_token=hf_token, - ) - if _gpu_available: - import torch - _diarization_pipeline.to(torch.device("cuda")) - logger.info("Diarization 파이프라인 로딩 완료 (GPU)") - else: - logger.info("Diarization 파이프라인 로딩 완료 (CPU)") - except Exception as exc: - logger.error("Diarization 파이프라인 로딩 실패: %s", exc) - raise - - return _diarization_pipeline - - -@app.post("/diarize") -async def diarize( - audio: UploadFile = File(...), - hf_token: str = Form(default=""), - num_speakers: int = Form(default=0), -) -> JSONResponse: - """오디오 파일의 화자 구분을 수행한다.""" - if not hf_token: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "HuggingFace 토큰이 필요합니다"}, - ) - - start_time = time.monotonic() - - try: - import tempfile - import os - - pipeline = _get_diarization_pipeline(hf_token) - - # 오디오 파일 임시 저장 (pyannote는 파일 경로 필요) - pcm_bytes = await audio.read() - if len(pcm_bytes) == 0: - return JSONResponse( - status_code=400, - content={"status": "error", "message": "오디오 데이터가 비어있습니다"}, - ) - - # PCM16 → WAV 변환 - import wave - tmp_fd, tmp_path = tempfile.mkstemp(suffix=".wav") - try: - with wave.open(tmp_path, "wb") as wf: - wf.setnchannels(1) - wf.setsampwidth(2) # 16-bit - wf.setframerate(16000) - wf.writeframes(pcm_bytes) - - # diarization 실행 - diarize_kwargs = {} - if num_speakers > 0: - diarize_kwargs["num_speakers"] = num_speakers - - logger.info("화자 구분 시작: %.1f초 오디오", len(pcm_bytes) / (16000 * 2)) - diarization = pipeline(tmp_path, **diarize_kwargs) - - # 결과 파싱 - segments = [] - speakers = set() - for turn, _, speaker in diarization.itertracks(yield_label=True): - segments.append({ - "speaker": speaker, - "start": round(turn.start, 3), - "end": round(turn.end, 3), - }) - speakers.add(speaker) - - processing_time = int((time.monotonic() - start_time) * 1000) - logger.info( - "화자 구분 완료: %d개 세그먼트, %d명 화자, %dms", - len(segments), - len(speakers), - processing_time, - ) - - return JSONResponse( - content={ - "segments": segments, - "num_speakers": len(speakers), - "processing_time": processing_time, - } - ) - finally: - os.close(tmp_fd) - os.unlink(tmp_path) - - except Exception as exc: - logger.error("화자 구분 실패: %s", exc, exc_info=True) - return JSONResponse( - status_code=500, - content={"status": "error", "message": str(exc)}, - ) - - @app.post("/shutdown") async def shutdown() -> JSONResponse: """서버를 graceful하게 종료한다.""" @@ -430,7 +301,6 @@ def main() -> None: logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port) - # SIGINT/SIGTERM 핸들러 def signal_handler(signum: int, _frame: object) -> None: sig_name = signal.Signals(signum).name logger.info("시그널 수신: %s, 종료 시작", sig_name) @@ -440,12 +310,11 @@ def main() -> None: signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGTERM, signal_handler) - # uvicorn 서버 구성 및 시작 config = uvicorn.Config( app=app, host=args.host, port=args.port, - log_level="warning", # uvicorn 자체 로그는 최소화 (우리 로거 사용) + log_level="warning", access_log=False, ) _server = uvicorn.Server(config) diff --git a/apps/desktop/sidecar/requirements.txt b/apps/desktop/sidecar/requirements.txt index 34d7905..afb99ca 100644 --- a/apps/desktop/sidecar/requirements.txt +++ b/apps/desktop/sidecar/requirements.txt @@ -3,5 +3,3 @@ fastapi>=0.109.0 uvicorn>=0.27.0 python-multipart>=0.0.6 numpy>=1.24.0 -pyannote.audio>=3.3.0 -torch>=2.0.0 diff --git a/apps/desktop/src/main/ipc/llm-handlers.ts b/apps/desktop/src/main/ipc/llm-handlers.ts index cb57572..447a93b 100644 --- a/apps/desktop/src/main/ipc/llm-handlers.ts +++ b/apps/desktop/src/main/ipc/llm-handlers.ts @@ -24,6 +24,21 @@ export function registerLLMHandlers(): void { safeSendToRenderer(IPC_CHANNELS.LLM.STATUS_CHANGED, { status: llm.getStatus() }) }) + // Pull 진행률 → 렌더러 + llm.on('pull-progress', (payload: unknown) => { + safeSendToRenderer(IPC_CHANNELS.LLM.PULL_PROGRESS, payload) + }) + + ipcMain.handle(IPC_CHANNELS.LLM.PULL_MODEL, async (_event, params: { modelId: string }) => { + try { + await getLocalLLMService().pullModel(params.modelId) + return ipcSuccess(undefined) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + return ipcError(ErrorCode.LLMServerUnreachable, `Pull 실패: ${msg}`) + } + }) + // Phase 3.2: VoiceModeService의 premium-llm-fallback 이벤트를 렌더러로 전달 getVoiceModeService().on('premium-llm-fallback', (payload: { reason: string }) => { safeSendToRenderer(IPC_CHANNELS.LLM.PREMIUM_FALLBACK, payload) diff --git a/apps/desktop/src/main/services/LocalLLMService.ts b/apps/desktop/src/main/services/LocalLLMService.ts index 66263dc..6467fbc 100644 --- a/apps/desktop/src/main/services/LocalLLMService.ts +++ b/apps/desktop/src/main/services/LocalLLMService.ts @@ -11,6 +11,7 @@ import { configGet } from './ConfigService' import { D3ROError, ErrorCode } from '@d3ro/core/errors' import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types' import { resolveSystemPrompt } from './llm-prompts' +import { getBundledOllamaPath } from '../utils/paths' const logger = getLogger('LocalLLMService') @@ -167,6 +168,12 @@ class LocalLLMService extends EventEmitter { private async _findOllamaBinary(): Promise { const candidates: string[] = [] + // 1순위: 설치 파일에 번들된 ollama + const bundled = getBundledOllamaPath() + if (bundled) { + candidates.push(bundled) + } + if (process.platform === 'win32') { const localAppData = process.env.LOCALAPPDATA if (localAppData) { @@ -482,6 +489,77 @@ class LocalLLMService extends EventEmitter { } } + /** + * Ollama /api/pull — 모델 다운로드. 진행률을 EventEmitter로 방출. + * 스트리밍 JSON 라인을 파싱해 각 chunk마다 'pull-progress' 이벤트 emit. + * 완료 시 resolve, 에러 시 reject. + * + * @param modelId 예: 'gemma4:e4b' + */ + async pullModel(modelId: string): Promise { + const serverUrl = configGet('ollamaServerUrl') + logger.info(`Pull 시작: ${modelId}`) + + const response = await fetch(`${serverUrl}/api/pull`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ model: modelId, stream: true }) + }) + + if (!response.ok || !response.body) { + throw new D3ROError( + ErrorCode.LLMServerUnreachable, + `Pull 실패: HTTP ${response.status}` + ) + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let buffer = '' + + for (;;) { + const { done, value } = await reader.read() + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + + for (const line of lines) { + const trimmed = line.trim() + if (trimmed.length === 0) continue + try { + const chunk = JSON.parse(trimmed) as { + status: string + digest?: string + total?: number + completed?: number + error?: string + } + if (chunk.error) { + throw new D3ROError(ErrorCode.LLMServerUnreachable, chunk.error) + } + this.emit('pull-progress', { + modelId, + status: chunk.status, + digest: chunk.digest ?? null, + total: chunk.total ?? 0, + completed: chunk.completed ?? 0, + percent: + chunk.total && chunk.total > 0 + ? Math.min(100, Math.floor(((chunk.completed ?? 0) / chunk.total) * 100)) + : 0 + }) + } catch (err) { + if (err instanceof D3ROError) throw err + // JSON 파싱 실패한 부분 라인은 스킵 + } + } + } + + logger.info(`Pull 완료: ${modelId}`) + } + getStatus(): LLMStatus { const connectionState: LLMConnectionState = this._available ? this._state === LLMState.Generating diff --git a/apps/desktop/src/main/utils/paths.ts b/apps/desktop/src/main/utils/paths.ts index fb8e61b..0a9449d 100644 --- a/apps/desktop/src/main/utils/paths.ts +++ b/apps/desktop/src/main/utils/paths.ts @@ -88,6 +88,20 @@ export function getSidecarCommand(): { command: string; args: string[] } { return { command: pythonCmd, args: [sidecarPath] } } +/** + * 번들된 Ollama 실행 파일 경로. 존재하지 않으면 null을 반환해 시스템 설치본 탐색으로 폴백. + * - Windows: ollama.exe + * - macOS/Linux: ollama + */ +export function getBundledOllamaPath(): string | null { + const ollamaBin = `ollama${EXE_SUFFIX}` + const bundled = isPackaged() + ? path.join(process.resourcesPath, 'ollama', ollamaBin) + : path.join(app.getAppPath(), 'resources', 'ollama', ollamaBin) + + return existsSync(bundled) ? bundled : null +} + /** * 효과음 파일 경로. */ diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index 2331859..df7a40f 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -300,6 +300,18 @@ const electronAPI = { on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb), onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe => on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb), + pullModel: (params: { modelId: string }) => + invoke(IPC_CHANNELS.LLM.PULL_MODEL, params), + onPullProgress: ( + cb: (e: { + modelId: string + status: string + digest: string | null + total: number + completed: number + percent: number + }) => void + ): Unsubscribe => on(IPC_CHANNELS.LLM.PULL_PROGRESS, cb), // Phase 3.2: Premium LLM premium: { getStatus: () => diff --git a/apps/desktop/src/renderer/components/OnboardingModal.tsx b/apps/desktop/src/renderer/components/OnboardingModal.tsx index fde8f4a..b67564e 100644 --- a/apps/desktop/src/renderer/components/OnboardingModal.tsx +++ b/apps/desktop/src/renderer/components/OnboardingModal.tsx @@ -1,27 +1,30 @@ // src/renderer/components/OnboardingModal.tsx -// 첫 실행 시 마이크 + 핫키 설정 안내 +// 첫 실행 온보딩 모달 — 기본 LLM 모델(gemma4:e4b) 미설치 시 다운로드 유도. +// +// 두 경로로 열림: +// 1) AppLayout의 첫 실행 감지(onboardingCompleted=false) +// 2) 런타임 중 모델 미설치 감지 (주기적 polling) +// 다운로드 성공 시 config.onboardingCompleted=true로 저장. -import { useState, useEffect } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { Dialog, + DialogTitle, DialogContent, - Box, - Typography, + DialogActions, Button, - Stack, - Chip, + Typography, + Box, + LinearProgress, } from '@mui/material' -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 CloudIcon from '@mui/icons-material/Cloud' -import { d3roPalette, d3roFontMono, d3roShadow } from '@d3ro/ui/theme' -import { Led } from '@d3ro/ui/components/ds' -import { HotkeyRecordModal } from './HotkeyRecordModal' -import { formatHotkeyLabel, formatHotkeySegments } from '../utils/format-hotkey' +import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline' +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline' +import CloudDownloadIcon from '@mui/icons-material/CloudDownload' +import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme' import { useI18n } from '@d3ro/i18n' -import type { HotkeyBinding, AudioDevice } from '@d3ro/core/types' + +const DEFAULT_MODEL = 'gemma4:e4b' +type Phase = 'prompt' | 'downloading' | 'success' | 'failed' interface OnboardingModalProps { open: boolean @@ -30,310 +33,240 @@ interface OnboardingModalProps { export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement { const { t } = useI18n() - // 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: Cloud Sync(선택), 5: 완료 - const [step, setStep] = useState(0) - const [devices, setDevices] = useState([]) - const [selectedDevice, setSelectedDevice] = useState('default') - const [hotkeyBinding, setHotkeyBinding] = useState(null) - const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false) + const [internalOpen, setInternalOpen] = useState(false) + const [phase, setPhase] = useState('prompt') + const [percent, setPercent] = useState(0) + const [status, setStatus] = useState('') + const [errorMsg, setErrorMsg] = useState('') + const unsubRef = useRef<(() => void) | null>(null) + + const isVisible = open || internalOpen + + // 모델 존재 여부 체크 — 없으면 auto-open + const checkModels = useCallback(async (): Promise => { + try { + const result = await window.electronAPI.llm.getModels() + if (!result.success) { + setPhase('prompt') + setInternalOpen(true) + return + } + const hasDefault = result.data.some((m) => m.id === DEFAULT_MODEL) + if (!hasDefault) { + setPhase('prompt') + setInternalOpen(true) + } else { + setInternalOpen(false) + } + } catch { + setPhase('prompt') + setInternalOpen(true) + } + }, []) + useEffect(() => { - if (!open) return - setStep(0) - window.electronAPI.audio.getDevices().then((r) => { - if (r.success) setDevices(r.data) - }) - window.electronAPI.hotkey.getDictationShortcut().then((r) => { - if (r.success && r.data) setHotkeyBinding(r.data) - }) - }, [open]) + const initialCheck = setTimeout(() => void checkModels(), 2000) + const interval = setInterval(() => { + if (phase === 'prompt') void checkModels() + }, 15000) + return () => { + clearTimeout(initialCheck) + clearInterval(interval) + } + }, [checkModels, phase]) - const handleFinish = (): void => { - // 온보딩 완료 플래그 저장 - window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) + // pull 진행률 구독 + useEffect(() => { + const unsub = window.electronAPI.llm.onPullProgress((e) => { + if (e.modelId !== DEFAULT_MODEL) return + setStatus(e.status) + if (e.percent > 0) setPercent(e.percent) + }) + unsubRef.current = unsub + return () => { + unsub() + unsubRef.current = null + } + }, []) + + const handleDownload = useCallback(async (): Promise => { + setPhase('downloading') + setPercent(0) + setStatus('') + setErrorMsg('') + + const result = await window.electronAPI.llm.pullModel({ modelId: DEFAULT_MODEL }) + if (result.success) { + setPhase('success') + setPercent(100) + // 온보딩 완료 플래그 저장 + window.electronAPI.config.set({ key: 'onboardingCompleted', value: true }) + } else { + setPhase('failed') + setErrorMsg(result.error?.message ?? 'unknown') + } + }, []) + + const handleClose = useCallback(() => { + if (phase === 'downloading') return + setInternalOpen(false) onClose() - } + }, [phase, onClose]) - // Ollama step(3) 다음 → Cloud Sync step(4)로 - const nextAfterOllama = (): void => { - setStep(4) - } + if (!isVisible) return <> - // Cloud Sync step에서 Back 누르면 Ollama(3)로 복귀 - const backToOllama = (): void => { - setStep(3) - } + const isDownloading = phase === 'downloading' + const isSuccess = phase === 'success' + const isFailed = phase === 'failed' - const handleHotkeySave = (binding: HotkeyBinding) => { - setHotkeyBinding(binding) - window.electronAPI.hotkey.setDictationShortcut({ binding }) - window.electronAPI.hotkey.setEnabled({ enabled: true }) - } + const colorSuccess = d3roPalette.tag.green + const colorDanger = d3roPalette.tag.red + const colorAccent = d3roPalette.accent.amber return ( - <> - + - - {/* Step 0: 환영 */} - {step === 0 && ( - - + {isSuccess ? ( + + ) : isFailed ? ( + + ) : ( + + )} + {t('onboarding.title')} + + + + + {t('onboarding.subtitle')} + + + {(phase === 'prompt' || isDownloading) && ( + + + {t('onboarding.llmModelMissing', { model: DEFAULT_MODEL })} + + + {t('onboarding.llmModelSize')} + + + )} + + {isDownloading && ( + + + + {t('onboarding.downloading')} + + + {percent}% + + + + {status && ( - D3RO-VOICE + {t('onboarding.status', { status })} - - {t('onboarding.welcome.desc')} - - - - )} + )} + + )} - {/* Step 1: 마이크 */} - {step === 1 && ( - - - - - {t('onboarding.mic.title')} - - - - {t('onboarding.mic.desc')} - - - {devices.map((d, idx) => ( - { - setSelectedDevice(d.deviceId) - window.electronAPI.audio.setSelectedDevice({ deviceId: d.deviceId }) - }} - sx={{ - p: 1.5, - borderRadius: '8px', - cursor: 'pointer', - bgcolor: selectedDevice === d.deviceId ? d3roPalette.accent.amberDim : d3roPalette.bg.inset, - border: selectedDevice === d.deviceId - ? `1px solid ${d3roPalette.accent.amber}` - : `1px solid ${d3roPalette.border.subtle}`, - '&:hover': { bgcolor: d3roPalette.bg.cardHover }, - }} - > - - {d.label}{d.isDefault ? ` ${t('settings.deviceDefault')}` : ''} - - - ))} - - - - - - - )} + {isSuccess && ( + + {t('onboarding.success')} + + )} - {/* Step 2: 핫키 */} - {step === 2 && ( - - - - - {t('onboarding.hotkey.title')} - - - - {t('onboarding.hotkey.desc')} - - - {hotkeyBinding ? ( - - - {formatHotkeySegments(hotkeyBinding).map((key, idx) => ( - - ))} - - ) : ( - - {t('onboarding.hotkey.notSet')} - - )} - - - - - - - - )} + {isFailed && ( + + {t('onboarding.failed', { message: errorMsg })} + + )} + - {/* Step 3: Ollama 설치 */} - {step === 3 && ( - - - - - {t('onboarding.ollama.title')} - - - - {t('onboarding.ollama.desc')} - - - - - $ ollama pull gemma4:e4b - - - {t('onboarding.ollama.modelHint')} - - - - - - - - )} + + {phase === 'prompt' && ( + <> + + + + )} - {/* Step 4: Cloud Sync (선택) */} - {step === 4 && ( - - - - - {t('onboarding.cloud.title')} - - - - {t('onboarding.cloud.tagline')} - - - - {[ - t('onboarding.cloud.benefit1'), - t('onboarding.cloud.benefit2'), - t('onboarding.cloud.benefit3'), - ].map((b, idx) => ( - - - - {b} - - - ))} - - - - {t('onboarding.cloud.signInLater')} - - - - - - - )} + {isDownloading && ( + + )} - {/* Step 5: 완료 */} - {step === 5 && ( - - - - {t('onboarding.done.title')} - - - {hotkeyBinding - ? t('onboarding.done.descWithKey', { key: formatHotkeyLabel(hotkeyBinding) }) - : t('onboarding.done.descNoKey')} - - - - )} - - + {isSuccess && ( + + )} - setHotkeyModalOpen(false)} - onSave={handleHotkeySave} - currentBinding={hotkeyBinding} - title={t('hotkey.dictationTitle')} - /> - + {isFailed && ( + <> + + + + )} + + ) } diff --git a/memory/project_status.md b/memory/project_status.md index dd482c6..79575c7 100644 --- a/memory/project_status.md +++ b/memory/project_status.md @@ -1,6 +1,40 @@ # D3RO-VOICE 프로젝트 현황 -> 마지막 갱신: 2026-04-13 (RN CLI 전환 + 안드로이드 빌드 성공) +> 마지막 갱신: 2026-04-15 (배포 파이프라인 기반 구축 — 번들 설치러너 + GitLab CI) + +## 배포 파이프라인 기반 구축 (2026-04-15) ✅ + +### 결정 사항 +- **기본 LLM 모델**: `gemma4:e4b` (9.6GB, 텍스트+이미지+오디오 멀티모달) +- **Ollama 배포 방식**: 바이너리 **번들** (포터블 zip을 `resources/ollama/`에 동봉) +- **Whisper 기본 모델**: `large-v3` (3GB, 첫 실행 시 자동 다운로드) +- **Sidecar 슬림화**: `torch`, `pyannote.audio` 제거 → GPU 감지는 `ctranslate2` 사용. `/diarize` 엔드포인트 삭제. 화자 구분은 LLM 추정 경로로 폴백(기존 MeetingModeService가 자동 처리). +- **SaaS 지향**: 고정밀 화자 구분은 추후 서버 사이드 API로 제공 예정. + +### 변경된 파일 +- `apps/desktop/sidecar/main.py`: torch/pyannote 의존 제거, `/diarize` 삭제, ctranslate2 GPU 감지 +- `apps/desktop/sidecar/requirements.txt`: torch/pyannote 제거 (faster-whisper + fastapi + uvicorn + numpy만) +- `apps/desktop/src/main/utils/paths.ts`: `getBundledOllamaPath()` 추가 +- `apps/desktop/src/main/services/LocalLLMService.ts`: `_findOllamaBinary()`가 번들 ollama를 1순위로 탐색 +- `apps/desktop/electron-builder.yml`: `resources/ollama/` extraResources + `nsis.include: build/installer.nsh` +- `apps/desktop/scripts/download-ollama.ps1` (Windows), `download-ollama.sh` (mac/linux) 신규 +- `apps/desktop/build/installer.nsh` 신규 — VC++ 재배포 확인 + 언인스톨 시 번들 ollama 종료 +- `.gitlab-ci.yml`: Windows self-hosted runner에서 `sidecar 빌드 → sox 다운로드 → ollama 다운로드 → electron-builder` 파이프라인. 태그 푸시 시 GitLab Release 자동 생성. +- `.gitignore`: `resources/ollama/` 빌드 산출물 무시 + +### 다음 작업 +1. Windows self-hosted GitLab runner 등록 (tags: `windows`, `shell`) +2. 첫 실행 bootstrap UI — `gemma4:e4b` + Whisper `large-v3` 다운로드 진행률 모달 + - 위치: `Dashboard` 진입 전 게이트 + - IPC 채널: 설계서 02의 `ipc-channels.ts`에 `setup:pull-model`, `setup:download-whisper`, `setup:progress` 추가 필요 +3. VC++ 재배포 자동 다운로드(inetc plugin 검증 필요) 또는 번들화(25MB 추가) +4. 첫 태그 푸시(예: `v0.1.0-alpha`)로 CI 검증 + +### 차단 이슈 (기존) +- `@nut-tree-fork/nut-js` 포크 사용 (변경 없음) + +--- + ## RN CLI 전환 (2026-04-13) ✅ diff --git a/packages/i18n/src/locales/en.json b/packages/i18n/src/locales/en.json index a957a61..3dfcd35 100644 --- a/packages/i18n/src/locales/en.json +++ b/packages/i18n/src/locales/en.json @@ -613,5 +613,19 @@ "mobile.status.link": "PRECISION DATA LINK", "date.today": "TODAY", - "date.yesterday": "YESTERDAY" + "date.yesterday": "YESTERDAY", + + "onboarding.title": "D3RO Voice Initial Setup", + "onboarding.subtitle": "Download the model required to use the voice assistant.", + "onboarding.llmModelMissing": "AI model ({model}) is not installed.", + "onboarding.llmModelSize": "Size: ~9.6GB — takes 10–30 min depending on network speed", + "onboarding.download": "Start Download", + "onboarding.cancel": "Later", + "onboarding.close": "Close", + "onboarding.downloading": "Downloading…", + "onboarding.status": "Status: {status}", + "onboarding.success": "Setup complete! You can now use the app.", + "onboarding.failed": "Download failed: {message}", + "onboarding.retry": "Retry", + "onboarding.ollamaNotRunning": "Ollama server is not running. Please retry shortly." } diff --git a/packages/i18n/src/locales/ko.json b/packages/i18n/src/locales/ko.json index f899929..bc7518f 100644 --- a/packages/i18n/src/locales/ko.json +++ b/packages/i18n/src/locales/ko.json @@ -613,5 +613,19 @@ "mobile.status.link": "PRECISION DATA LINK", "date.today": "TODAY", - "date.yesterday": "YESTERDAY" + "date.yesterday": "YESTERDAY", + + "onboarding.title": "D3RO Voice 초기 설정", + "onboarding.subtitle": "음성 어시스턴트 사용에 필요한 모델을 다운로드합니다.", + "onboarding.llmModelMissing": "AI 모델({model})이 설치되어 있지 않습니다.", + "onboarding.llmModelSize": "크기: 약 9.6GB — 네트워크 속도에 따라 10~30분 소요", + "onboarding.download": "다운로드 시작", + "onboarding.cancel": "나중에", + "onboarding.close": "닫기", + "onboarding.downloading": "다운로드 중…", + "onboarding.status": "상태: {status}", + "onboarding.success": "설치 완료! 이제 사용할 수 있습니다.", + "onboarding.failed": "다운로드 실패: {message}", + "onboarding.retry": "재시도", + "onboarding.ollamaNotRunning": "Ollama 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요." }