feat: 배포 파이프라인 — Ollama/sidecar 번들 + NSIS 자동 VC++ + GitLab CI + 온보딩 모달

- 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 자동 생성
This commit is contained in:
yunchan8804 2026-04-15 19:47:27 +09:00
parent d1edad6727
commit aa65e710ec
16 changed files with 725 additions and 500 deletions

5
.gitignore vendored
View file

@ -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/

View file

@ -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+/

View file

@ -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

View file

@ -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

View file

@ -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
}

View file

@ -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"

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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<string | null> {
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<void> {
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

View file

@ -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
}
/**
* .
*/

View file

@ -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<void>(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: () =>

View file

@ -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<AudioDevice[]>([])
const [selectedDevice, setSelectedDevice] = useState('default')
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
const [internalOpen, setInternalOpen] = useState(false)
const [phase, setPhase] = useState<Phase>('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<void> => {
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<void> => {
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 (
<>
<Dialog
open={open}
maxWidth="sm"
fullWidth
PaperProps={{
sx: {
bgcolor: d3roPalette.bg.chassis,
backgroundImage: 'none',
border: `1px solid ${d3roPalette.border.subtle}`,
boxShadow: d3roShadow.chassis,
},
<Dialog
open={isVisible}
onClose={handleClose}
maxWidth="sm"
fullWidth
disableEscapeKeyDown={isDownloading}
PaperProps={{
sx: {
borderRadius: d3roRadius.card,
backgroundColor: d3roPalette.bg.elevated,
border: `1px solid ${d3roPalette.border.subtle}`,
},
}}
>
<DialogTitle
sx={{
display: 'flex',
alignItems: 'center',
gap: 1.5,
...typoSx('heading'),
color: d3roPalette.text.primary,
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
}}
>
<DialogContent sx={{ p: 4 }}>
{/* Step 0: 환영 */}
{step === 0 && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<Led color="amber" pulse size={16} />
{isSuccess ? (
<CheckCircleOutlineIcon sx={{ color: colorSuccess }} />
) : isFailed ? (
<ErrorOutlineIcon sx={{ color: colorDanger }} />
) : (
<CloudDownloadIcon sx={{ color: colorAccent }} />
)}
{t('onboarding.title')}
</DialogTitle>
<DialogContent sx={{ py: 3 }}>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.subtitle')}
</Typography>
{(phase === 'prompt' || isDownloading) && (
<Box
sx={{
p: 2,
borderRadius: d3roRadius.inner,
backgroundColor: d3roPalette.bg.card,
border: `1px solid ${d3roPalette.border.subtle}`,
mb: 2,
}}
>
<Typography sx={{ ...typoSx('body'), color: d3roPalette.text.primary, mb: 1 }}>
{t('onboarding.llmModelMissing', { model: DEFAULT_MODEL })}
</Typography>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{t('onboarding.llmModelSize')}
</Typography>
</Box>
)}
{isDownloading && (
<Box sx={{ mt: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Typography sx={{ ...typoSx('small'), color: d3roPalette.text.secondary }}>
{t('onboarding.downloading')}
</Typography>
<Typography sx={{ ...typoSx('small'), color: colorAccent }}>
{percent}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={percent}
sx={{ height: 8, borderRadius: d3roRadius.xs }}
/>
{status && (
<Typography
sx={{
fontFamily: d3roFontMono,
fontSize: '24px',
fontWeight: 300,
color: d3roPalette.accent.amber,
mt: 3,
mb: 1,
}}
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
>
D3RO-VOICE
{t('onboarding.status', { status })}
</Typography>
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
{t('onboarding.welcome.desc')}
</Typography>
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
{t('onboarding.welcome.start')}
</Button>
</Box>
)}
)}
</Box>
)}
{/* Step 1: 마이크 */}
{step === 1 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<MicIcon sx={{ color: d3roPalette.accent.amber }} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
{t('onboarding.mic.title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.mic.desc')}
</Typography>
<Stack spacing={1} mb={3}>
{devices.map((d, idx) => (
<Box
key={`${d.deviceId}-${idx}`}
onClick={() => {
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 },
}}
>
<Typography variant="body2" sx={{ fontSize: '13px' }}>
{d.label}{d.isDefault ? ` ${t('settings.deviceDefault')}` : ''}
</Typography>
</Box>
))}
</Stack>
<Stack direction="row" justifyContent="space-between">
<Button onClick={() => setStep(0)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
<Button variant="contained" onClick={() => setStep(2)}>{t('onboarding.next')}</Button>
</Stack>
</Box>
)}
{isSuccess && (
<Typography sx={{ ...typoSx('body'), color: colorSuccess, mt: 2 }}>
{t('onboarding.success')}
</Typography>
)}
{/* Step 2: 핫키 */}
{step === 2 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
{t('onboarding.hotkey.title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.hotkey.desc')}
</Typography>
<Box
sx={{
p: 2,
borderRadius: '10px',
bgcolor: d3roPalette.bg.inset,
boxShadow: d3roShadow.inset,
textAlign: 'center',
mb: 3,
}}
>
{hotkeyBinding ? (
<Stack direction="row" spacing={1} justifyContent="center" alignItems="center">
<Led color="green" size={8} />
{formatHotkeySegments(hotkeyBinding).map((key, idx) => (
<Chip
key={`${key}-${idx}`}
label={key}
sx={{
fontFamily: d3roFontMono,
fontWeight: 700,
bgcolor: d3roPalette.bg.chassis,
color: d3roPalette.text.primary,
border: `1px solid ${d3roPalette.border.default}`,
}}
/>
))}
</Stack>
) : (
<Typography sx={{ color: d3roPalette.text.inactive, fontSize: '13px' }}>
{t('onboarding.hotkey.notSet')}
</Typography>
)}
</Box>
<Button
variant="outlined"
fullWidth
onClick={() => setHotkeyModalOpen(true)}
sx={{ mb: 3, fontFamily: d3roFontMono }}
>
{hotkeyBinding ? t('onboarding.hotkey.change') : t('onboarding.hotkey.set')}
</Button>
<Stack direction="row" justifyContent="space-between">
<Button onClick={() => setStep(1)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
<Button variant="contained" onClick={() => setStep(3)}>{t('onboarding.next')}</Button>
</Stack>
</Box>
)}
{isFailed && (
<Typography sx={{ ...typoSx('body'), color: colorDanger, mt: 2 }}>
{t('onboarding.failed', { message: errorMsg })}
</Typography>
)}
</DialogContent>
{/* Step 3: Ollama 설치 */}
{step === 3 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<Led color="amber" size={12} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
{t('onboarding.ollama.title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.ollama.desc')}
</Typography>
<Button
variant="outlined"
endIcon={<OpenInNewIcon sx={{ fontSize: 14 }} />}
onClick={() => window.electronAPI.system.openExternal({ url: 'https://ollama.com/download' })}
fullWidth
sx={{ mb: 1.5, fontFamily: d3roFontMono }}
>
{t('onboarding.ollama.download')}
</Button>
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: d3roPalette.bg.inset, boxShadow: d3roShadow.inset, mb: 3 }}>
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: d3roPalette.accent.amber }}>
$ ollama pull gemma4:e4b
</Typography>
<Typography sx={{ fontSize: '10px', color: d3roPalette.text.inactive, mt: 0.5 }}>
{t('onboarding.ollama.modelHint')}
</Typography>
</Box>
<Stack direction="row" justifyContent="space-between">
<Button onClick={() => setStep(2)} sx={{ color: d3roPalette.text.inactive }}>{t('onboarding.back')}</Button>
<Button variant="contained" onClick={nextAfterOllama}>{t('onboarding.next')}</Button>
</Stack>
</Box>
)}
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
{phase === 'prompt' && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.cancel')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.download')}
</Button>
</>
)}
{/* Step 4: Cloud Sync (선택) */}
{step === 4 && (
<Box>
<Stack direction="row" alignItems="center" gap={1} mb={3}>
<CloudIcon sx={{ color: d3roPalette.accent.amber }} />
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
{t('onboarding.cloud.title')}
</Typography>
</Stack>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
{t('onboarding.cloud.tagline')}
</Typography>
<Box
sx={{
p: 2,
borderRadius: '10px',
bgcolor: d3roPalette.bg.inset,
boxShadow: d3roShadow.inset,
mb: 3,
}}
>
<Stack spacing={1.25}>
{[
t('onboarding.cloud.benefit1'),
t('onboarding.cloud.benefit2'),
t('onboarding.cloud.benefit3'),
].map((b, idx) => (
<Stack key={idx} direction="row" spacing={1} alignItems="center">
<Led color="green" size={8} />
<Typography sx={{ fontSize: '12px', color: d3roPalette.text.primary }}>
{b}
</Typography>
</Stack>
))}
</Stack>
</Box>
<Typography
sx={{
fontSize: '11px',
color: d3roPalette.text.inactive,
mb: 3,
textAlign: 'center',
}}
>
{t('onboarding.cloud.signInLater')}
</Typography>
<Stack direction="row" justifyContent="space-between">
<Button onClick={backToOllama} sx={{ color: d3roPalette.text.inactive }}>
{t('onboarding.back')}
</Button>
<Button variant="contained" onClick={() => setStep(5)}>
{t('onboarding.next')}
</Button>
</Stack>
</Box>
)}
{isDownloading && (
<Button disabled sx={{ color: d3roPalette.text.disabled }}>
{t('onboarding.downloading')}
</Button>
)}
{/* Step 5: 완료 */}
{step === 5 && (
<Box sx={{ textAlign: 'center', py: 3 }}>
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
{t('onboarding.done.title')}
</Typography>
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
{hotkeyBinding
? t('onboarding.done.descWithKey', { key: formatHotkeyLabel(hotkeyBinding) })
: t('onboarding.done.descNoKey')}
</Typography>
<Button variant="contained" onClick={handleFinish} fullWidth>
{t('onboarding.done.start')}
</Button>
</Box>
)}
</DialogContent>
</Dialog>
{isSuccess && (
<Button
onClick={handleClose}
variant="contained"
sx={{ backgroundColor: colorSuccess }}
>
{t('onboarding.close')}
</Button>
)}
<HotkeyRecordModal
open={hotkeyModalOpen}
onClose={() => setHotkeyModalOpen(false)}
onSave={handleHotkeySave}
currentBinding={hotkeyBinding}
title={t('hotkey.dictationTitle')}
/>
</>
{isFailed && (
<>
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
{t('onboarding.close')}
</Button>
<Button
onClick={handleDownload}
variant="contained"
sx={{ backgroundColor: colorAccent }}
>
{t('onboarding.retry')}
</Button>
</>
)}
</DialogActions>
</Dialog>
)
}

View file

@ -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) ✅

View file

@ -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 1030 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."
}

View file

@ -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 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요."
}