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:
parent
d1edad6727
commit
aa65e710ec
16 changed files with 725 additions and 500 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -24,6 +24,11 @@ sidecar-dist/
|
||||||
# Bundled binaries (download via scripts)
|
# Bundled binaries (download via scripts)
|
||||||
resources/sox/*.exe
|
resources/sox/*.exe
|
||||||
resources/sox/*.dll
|
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
|
# IDE
|
||||||
.idea/
|
.idea/
|
||||||
|
|
|
||||||
107
.gitlab-ci.yml
107
.gitlab-ci.yml
|
|
@ -1,14 +1,17 @@
|
||||||
stages:
|
stages:
|
||||||
- check
|
- check
|
||||||
- test
|
- test
|
||||||
|
- prepare
|
||||||
- build
|
- build
|
||||||
|
- package
|
||||||
- release
|
- release
|
||||||
|
|
||||||
variables:
|
variables:
|
||||||
NODE_VERSION: "20"
|
NODE_VERSION: "20"
|
||||||
|
PYTHON_VERSION: "3.11"
|
||||||
|
OLLAMA_VERSION: "v0.5.7"
|
||||||
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
npm_config_cache: "$CI_PROJECT_DIR/.npm"
|
||||||
|
|
||||||
# Node.js 캐시
|
|
||||||
.node-cache: &node-cache
|
.node-cache: &node-cache
|
||||||
cache:
|
cache:
|
||||||
key:
|
key:
|
||||||
|
|
@ -18,8 +21,9 @@ variables:
|
||||||
- .npm/
|
- .npm/
|
||||||
- node_modules/
|
- node_modules/
|
||||||
|
|
||||||
# ── Check Stage ──────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Check
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
lint:
|
lint:
|
||||||
stage: check
|
stage: check
|
||||||
image: node:${NODE_VERSION}
|
image: node:${NODE_VERSION}
|
||||||
|
|
@ -31,6 +35,7 @@ lint:
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
stage: check
|
stage: check
|
||||||
|
|
@ -43,9 +48,11 @@ typecheck:
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
- if: $CI_COMMIT_TAG
|
||||||
|
|
||||||
# ── Test Stage ───────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Test
|
||||||
|
# ────────────────────────────────────────────────────────────────────
|
||||||
unit-test:
|
unit-test:
|
||||||
stage: test
|
stage: test
|
||||||
image: node:${NODE_VERSION}
|
image: node:${NODE_VERSION}
|
||||||
|
|
@ -58,42 +65,74 @@ unit-test:
|
||||||
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
|
||||||
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
|
||||||
|
|
||||||
# ── Build Stage ──────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
# Package Windows (태그 푸시 또는 수동 트리거 시만 실행)
|
||||||
build:
|
# - PyInstaller sidecar 빌드 → SoX/Ollama 다운로드 → electron-builder --win
|
||||||
stage: build
|
# - Windows self-hosted runner 필요 (native 모듈 + PyInstaller 네이티브 빌드)
|
||||||
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:
|
package-windows:
|
||||||
stage: release
|
stage: package
|
||||||
tags:
|
tags:
|
||||||
- windows # Windows 러너 필요 (native 모듈 빌드)
|
- windows
|
||||||
|
- shell # self-hosted Windows runner (shell executor)
|
||||||
|
variables:
|
||||||
|
CI_RUNNER_PROTECTED: "false"
|
||||||
before_script:
|
before_script:
|
||||||
|
# Node 20 + Python 3.11이 러너에 사전 설치되어 있어야 함
|
||||||
|
- node --version
|
||||||
|
- python --version
|
||||||
- npm ci
|
- npm ci
|
||||||
script:
|
script:
|
||||||
- npm run build
|
# 1) Python sidecar 의존성 설치 + PyInstaller 빌드
|
||||||
- npm run dist
|
- 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:
|
artifacts:
|
||||||
|
name: "d3ro-voice-windows-${CI_COMMIT_SHORT_SHA}"
|
||||||
paths:
|
paths:
|
||||||
- release/
|
- apps/desktop/release/
|
||||||
expire_in: 30 days
|
expire_in: 90 days
|
||||||
|
expose_as: "Windows Installer"
|
||||||
rules:
|
rules:
|
||||||
- if: $CI_COMMIT_TAG =~ /^v\d+\.\d+\.\d+/
|
- 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 크로스빌드 또는
|
# Release (태그 시 GitLab Releases 페이지에 자동 첨부)
|
||||||
# Windows self-hosted runner 등록
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
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+/
|
||||||
|
|
|
||||||
60
apps/desktop/build/installer.nsh
Normal file
60
apps/desktop/build/installer.nsh
Normal 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
|
||||||
|
|
@ -26,15 +26,6 @@ win:
|
||||||
- x64
|
- x64
|
||||||
icon: resources/icons/icon.ico
|
icon: resources/icons/icon.ico
|
||||||
|
|
||||||
nsis:
|
|
||||||
oneClick: false
|
|
||||||
perMachine: false
|
|
||||||
allowToChangeInstallationDirectory: true
|
|
||||||
createDesktopShortcut: true
|
|
||||||
createStartMenuShortcut: true
|
|
||||||
shortcutName: D3RO Voice
|
|
||||||
deleteAppDataOnUninstall: false
|
|
||||||
|
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
# macOS
|
# macOS
|
||||||
# ────────────────────────────────────────────────────────────────────
|
# ────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -111,4 +102,23 @@ extraResources:
|
||||||
filter:
|
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
|
npmRebuild: true
|
||||||
|
|
|
||||||
69
apps/desktop/scripts/download-ollama.ps1
Normal file
69
apps/desktop/scripts/download-ollama.ps1
Normal 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
|
||||||
|
}
|
||||||
61
apps/desktop/scripts/download-ollama.sh
Normal file
61
apps/desktop/scripts/download-ollama.sh
Normal 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"
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""
|
"""
|
||||||
D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
|
D3RO-VOICE STT Sidecar (FastAPI HTTP 서버)
|
||||||
faster-whisper를 사용한 로컬 음성 인식 서비스.
|
faster-whisper + CTranslate2만 사용. torch/pyannote 비의존으로 슬림 배포.
|
||||||
|
|
||||||
사용법:
|
사용법:
|
||||||
python main.py --port 18765
|
python main.py --port 18765
|
||||||
|
|
@ -10,6 +10,9 @@ faster-whisper를 사용한 로컬 음성 인식 서비스.
|
||||||
POST /load - Whisper 모델 로딩
|
POST /load - Whisper 모델 로딩
|
||||||
POST /transcribe - 오디오 전사 (multipart)
|
POST /transcribe - 오디오 전사 (multipart)
|
||||||
POST /shutdown - 서버 종료
|
POST /shutdown - 서버 종료
|
||||||
|
|
||||||
|
주: 화자 구분(diarization)은 Phase 15.5에서 LLM 추정 경로가 primary이며,
|
||||||
|
pyannote 기반 고정밀 화자 구분은 추후 서버 사이드 API로 제공될 예정.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -61,20 +64,24 @@ app = FastAPI(title="D3RO-VOICE STT Sidecar", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
||||||
def _detect_gpu() -> None:
|
def _detect_gpu() -> None:
|
||||||
"""GPU(CUDA) 사용 가능 여부를 감지한다."""
|
"""GPU(CUDA) 사용 가능 여부를 ctranslate2로 감지한다.
|
||||||
|
|
||||||
|
torch 의존 제거를 위해 ctranslate2의 네이티브 CUDA 감지를 사용한다.
|
||||||
|
ctranslate2는 faster-whisper의 백엔드이므로 항상 함께 설치된다.
|
||||||
|
"""
|
||||||
global _gpu_available
|
global _gpu_available
|
||||||
try:
|
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:
|
if _gpu_available:
|
||||||
device_name = torch.cuda.get_device_name(0)
|
logger.info("GPU 감지: CUDA 디바이스 %d개", cuda_count)
|
||||||
logger.info("GPU 감지: %s", device_name)
|
|
||||||
else:
|
else:
|
||||||
logger.info("GPU 미감지, CPU 모드로 동작")
|
logger.info("GPU 미감지, CPU 모드로 동작")
|
||||||
except ImportError:
|
except Exception as exc:
|
||||||
_gpu_available = False
|
_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 모델을 로딩한다.
|
"""Whisper 모델을 로딩한다.
|
||||||
|
|
||||||
Request body:
|
Request body:
|
||||||
{ "model_id": "base" } -- tiny, base, small, medium, large-v3
|
{ "model_id": "large-v3" } -- tiny, base, small, medium, large-v3
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
{ "status": "loaded", "model_id": "base", "load_time_ms": 1234 }
|
{ "status": "loaded", "model_id": "large-v3", "load_time_ms": 1234 }
|
||||||
"""
|
"""
|
||||||
global _model, _model_id
|
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)
|
logger.info("모델 로딩 시작: %s", model_id)
|
||||||
|
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
|
@ -115,10 +122,6 @@ async def load_model(body: dict) -> JSONResponse: # noqa: ANN001
|
||||||
device = "cuda" if _gpu_available else "cpu"
|
device = "cuda" if _gpu_available else "cpu"
|
||||||
compute_type = "float16" if _gpu_available else "int8"
|
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 = WhisperModel(
|
||||||
model_id,
|
model_id,
|
||||||
device=device,
|
device=device,
|
||||||
|
|
@ -165,15 +168,6 @@ async def transcribe(
|
||||||
language - 언어 코드 ('auto', 'ko', 'en', ...)
|
language - 언어 코드 ('auto', 'ko', 'en', ...)
|
||||||
vad_filter - VAD 필터 활성화 ('true' / 'false')
|
vad_filter - VAD 필터 활성화 ('true' / 'false')
|
||||||
initial_prompt - 초기 프롬프트 (컨텍스트 힌트)
|
initial_prompt - 초기 프롬프트 (컨텍스트 힌트)
|
||||||
|
|
||||||
Returns:
|
|
||||||
{
|
|
||||||
"text": "전사된 텍스트",
|
|
||||||
"segments": [...],
|
|
||||||
"language": "ko",
|
|
||||||
"duration": 3.5,
|
|
||||||
"processing_time": 1234
|
|
||||||
}
|
|
||||||
"""
|
"""
|
||||||
if _model is None:
|
if _model is None:
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
|
|
@ -184,7 +178,6 @@ async def transcribe(
|
||||||
start_time = time.monotonic()
|
start_time = time.monotonic()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# PCM16 바이너리 읽기
|
|
||||||
pcm_bytes = await audio.read()
|
pcm_bytes = await audio.read()
|
||||||
|
|
||||||
if len(pcm_bytes) == 0:
|
if len(pcm_bytes) == 0:
|
||||||
|
|
@ -193,12 +186,10 @@ async def transcribe(
|
||||||
content={"status": "error", "message": "오디오 데이터가 비어있습니다"},
|
content={"status": "error", "message": "오디오 데이터가 비어있습니다"},
|
||||||
)
|
)
|
||||||
|
|
||||||
# PCM16 → float32 변환 (-1.0 ~ 1.0)
|
|
||||||
audio_array = (
|
audio_array = (
|
||||||
np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
np.frombuffer(pcm_bytes, dtype=np.int16).astype(np.float32) / 32768.0
|
||||||
)
|
)
|
||||||
|
|
||||||
# 오디오 길이 계산 (16kHz mono 기준)
|
|
||||||
sample_rate = 16000
|
sample_rate = 16000
|
||||||
audio_duration = len(audio_array) / sample_rate
|
audio_duration = len(audio_array) / sample_rate
|
||||||
|
|
||||||
|
|
@ -209,7 +200,6 @@ async def transcribe(
|
||||||
vad_filter,
|
vad_filter,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 전사 옵션 구성
|
|
||||||
transcribe_kwargs: dict = {
|
transcribe_kwargs: dict = {
|
||||||
"vad_filter": vad_filter.lower() == "true",
|
"vad_filter": vad_filter.lower() == "true",
|
||||||
"beam_size": 5,
|
"beam_size": 5,
|
||||||
|
|
@ -221,7 +211,6 @@ async def transcribe(
|
||||||
if initial_prompt:
|
if initial_prompt:
|
||||||
transcribe_kwargs["initial_prompt"] = initial_prompt
|
transcribe_kwargs["initial_prompt"] = initial_prompt
|
||||||
|
|
||||||
# faster-whisper 전사 실행
|
|
||||||
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
|
# VAD가 전체 오디오를 제거하면 max() 에러 발생 → VAD 없이 재시도
|
||||||
try:
|
try:
|
||||||
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
|
segments_iter, info = _model.transcribe(audio_array, **transcribe_kwargs)
|
||||||
|
|
@ -233,7 +222,6 @@ async def transcribe(
|
||||||
else:
|
else:
|
||||||
raise
|
raise
|
||||||
|
|
||||||
# 세그먼트 수집
|
|
||||||
segments_list: list[dict] = []
|
segments_list: list[dict] = []
|
||||||
full_text_parts: list[str] = []
|
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")
|
@app.post("/shutdown")
|
||||||
async def shutdown() -> JSONResponse:
|
async def shutdown() -> JSONResponse:
|
||||||
"""서버를 graceful하게 종료한다."""
|
"""서버를 graceful하게 종료한다."""
|
||||||
|
|
@ -430,7 +301,6 @@ def main() -> None:
|
||||||
|
|
||||||
logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port)
|
logger.info("D3RO-VOICE STT Sidecar 시작 (port=%d)", args.port)
|
||||||
|
|
||||||
# SIGINT/SIGTERM 핸들러
|
|
||||||
def signal_handler(signum: int, _frame: object) -> None:
|
def signal_handler(signum: int, _frame: object) -> None:
|
||||||
sig_name = signal.Signals(signum).name
|
sig_name = signal.Signals(signum).name
|
||||||
logger.info("시그널 수신: %s, 종료 시작", sig_name)
|
logger.info("시그널 수신: %s, 종료 시작", sig_name)
|
||||||
|
|
@ -440,12 +310,11 @@ def main() -> None:
|
||||||
signal.signal(signal.SIGINT, signal_handler)
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
signal.signal(signal.SIGTERM, signal_handler)
|
signal.signal(signal.SIGTERM, signal_handler)
|
||||||
|
|
||||||
# uvicorn 서버 구성 및 시작
|
|
||||||
config = uvicorn.Config(
|
config = uvicorn.Config(
|
||||||
app=app,
|
app=app,
|
||||||
host=args.host,
|
host=args.host,
|
||||||
port=args.port,
|
port=args.port,
|
||||||
log_level="warning", # uvicorn 자체 로그는 최소화 (우리 로거 사용)
|
log_level="warning",
|
||||||
access_log=False,
|
access_log=False,
|
||||||
)
|
)
|
||||||
_server = uvicorn.Server(config)
|
_server = uvicorn.Server(config)
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,3 @@ fastapi>=0.109.0
|
||||||
uvicorn>=0.27.0
|
uvicorn>=0.27.0
|
||||||
python-multipart>=0.0.6
|
python-multipart>=0.0.6
|
||||||
numpy>=1.24.0
|
numpy>=1.24.0
|
||||||
pyannote.audio>=3.3.0
|
|
||||||
torch>=2.0.0
|
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,21 @@ export function registerLLMHandlers(): void {
|
||||||
safeSendToRenderer(IPC_CHANNELS.LLM.STATUS_CHANGED, { status: llm.getStatus() })
|
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 이벤트를 렌더러로 전달
|
// Phase 3.2: VoiceModeService의 premium-llm-fallback 이벤트를 렌더러로 전달
|
||||||
getVoiceModeService().on('premium-llm-fallback', (payload: { reason: string }) => {
|
getVoiceModeService().on('premium-llm-fallback', (payload: { reason: string }) => {
|
||||||
safeSendToRenderer(IPC_CHANNELS.LLM.PREMIUM_FALLBACK, payload)
|
safeSendToRenderer(IPC_CHANNELS.LLM.PREMIUM_FALLBACK, payload)
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ import { configGet } from './ConfigService'
|
||||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||||
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types'
|
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types'
|
||||||
import { resolveSystemPrompt } from './llm-prompts'
|
import { resolveSystemPrompt } from './llm-prompts'
|
||||||
|
import { getBundledOllamaPath } from '../utils/paths'
|
||||||
|
|
||||||
const logger = getLogger('LocalLLMService')
|
const logger = getLogger('LocalLLMService')
|
||||||
|
|
||||||
|
|
@ -167,6 +168,12 @@ class LocalLLMService extends EventEmitter {
|
||||||
private async _findOllamaBinary(): Promise<string | null> {
|
private async _findOllamaBinary(): Promise<string | null> {
|
||||||
const candidates: string[] = []
|
const candidates: string[] = []
|
||||||
|
|
||||||
|
// 1순위: 설치 파일에 번들된 ollama
|
||||||
|
const bundled = getBundledOllamaPath()
|
||||||
|
if (bundled) {
|
||||||
|
candidates.push(bundled)
|
||||||
|
}
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
const localAppData = process.env.LOCALAPPDATA
|
const localAppData = process.env.LOCALAPPDATA
|
||||||
if (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 {
|
getStatus(): LLMStatus {
|
||||||
const connectionState: LLMConnectionState = this._available
|
const connectionState: LLMConnectionState = this._available
|
||||||
? this._state === LLMState.Generating
|
? this._state === LLMState.Generating
|
||||||
|
|
|
||||||
|
|
@ -88,6 +88,20 @@ export function getSidecarCommand(): { command: string; args: string[] } {
|
||||||
return { command: pythonCmd, args: [sidecarPath] }
|
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
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 효과음 파일 경로.
|
* 효과음 파일 경로.
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -300,6 +300,18 @@ const electronAPI = {
|
||||||
on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb),
|
on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb),
|
||||||
onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe =>
|
onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe =>
|
||||||
on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb),
|
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
|
// Phase 3.2: Premium LLM
|
||||||
premium: {
|
premium: {
|
||||||
getStatus: () =>
|
getStatus: () =>
|
||||||
|
|
|
||||||
|
|
@ -1,27 +1,30 @@
|
||||||
// src/renderer/components/OnboardingModal.tsx
|
// 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 {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
DialogTitle,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
Box,
|
DialogActions,
|
||||||
Typography,
|
|
||||||
Button,
|
Button,
|
||||||
Stack,
|
Typography,
|
||||||
Chip,
|
Box,
|
||||||
|
LinearProgress,
|
||||||
} from '@mui/material'
|
} from '@mui/material'
|
||||||
import MicIcon from '@mui/icons-material/Mic'
|
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
|
||||||
import KeyboardIcon from '@mui/icons-material/Keyboard'
|
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'
|
||||||
import CheckCircleIcon from '@mui/icons-material/CheckCircle'
|
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||||
import OpenInNewIcon from '@mui/icons-material/OpenInNew'
|
import { d3roPalette, d3roRadius, typoSx } from '@d3ro/ui/theme'
|
||||||
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 { useI18n } from '@d3ro/i18n'
|
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 {
|
interface OnboardingModalProps {
|
||||||
open: boolean
|
open: boolean
|
||||||
|
|
@ -30,310 +33,240 @@ interface OnboardingModalProps {
|
||||||
|
|
||||||
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.ReactElement {
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
// 0: 환영, 1: 마이크, 2: 핫키, 3: Ollama, 4: Cloud Sync(선택), 5: 완료
|
const [internalOpen, setInternalOpen] = useState(false)
|
||||||
const [step, setStep] = useState(0)
|
const [phase, setPhase] = useState<Phase>('prompt')
|
||||||
const [devices, setDevices] = useState<AudioDevice[]>([])
|
const [percent, setPercent] = useState(0)
|
||||||
const [selectedDevice, setSelectedDevice] = useState('default')
|
const [status, setStatus] = useState('')
|
||||||
const [hotkeyBinding, setHotkeyBinding] = useState<HotkeyBinding | null>(null)
|
const [errorMsg, setErrorMsg] = useState('')
|
||||||
const [hotkeyModalOpen, setHotkeyModalOpen] = useState(false)
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open) return
|
const initialCheck = setTimeout(() => void checkModels(), 2000)
|
||||||
setStep(0)
|
const interval = setInterval(() => {
|
||||||
window.electronAPI.audio.getDevices().then((r) => {
|
if (phase === 'prompt') void checkModels()
|
||||||
if (r.success) setDevices(r.data)
|
}, 15000)
|
||||||
})
|
return () => {
|
||||||
window.electronAPI.hotkey.getDictationShortcut().then((r) => {
|
clearTimeout(initialCheck)
|
||||||
if (r.success && r.data) setHotkeyBinding(r.data)
|
clearInterval(interval)
|
||||||
})
|
}
|
||||||
}, [open])
|
}, [checkModels, phase])
|
||||||
|
|
||||||
const handleFinish = (): void => {
|
// pull 진행률 구독
|
||||||
// 온보딩 완료 플래그 저장
|
useEffect(() => {
|
||||||
window.electronAPI.config.set({ key: 'onboardingCompleted', value: true })
|
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()
|
onClose()
|
||||||
}
|
}, [phase, onClose])
|
||||||
|
|
||||||
// Ollama step(3) 다음 → Cloud Sync step(4)로
|
if (!isVisible) return <></>
|
||||||
const nextAfterOllama = (): void => {
|
|
||||||
setStep(4)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cloud Sync step에서 Back 누르면 Ollama(3)로 복귀
|
const isDownloading = phase === 'downloading'
|
||||||
const backToOllama = (): void => {
|
const isSuccess = phase === 'success'
|
||||||
setStep(3)
|
const isFailed = phase === 'failed'
|
||||||
}
|
|
||||||
|
|
||||||
const handleHotkeySave = (binding: HotkeyBinding) => {
|
const colorSuccess = d3roPalette.tag.green
|
||||||
setHotkeyBinding(binding)
|
const colorDanger = d3roPalette.tag.red
|
||||||
window.electronAPI.hotkey.setDictationShortcut({ binding })
|
const colorAccent = d3roPalette.accent.amber
|
||||||
window.electronAPI.hotkey.setEnabled({ enabled: true })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Dialog
|
||||||
<Dialog
|
open={isVisible}
|
||||||
open={open}
|
onClose={handleClose}
|
||||||
maxWidth="sm"
|
maxWidth="sm"
|
||||||
fullWidth
|
fullWidth
|
||||||
PaperProps={{
|
disableEscapeKeyDown={isDownloading}
|
||||||
sx: {
|
PaperProps={{
|
||||||
bgcolor: d3roPalette.bg.chassis,
|
sx: {
|
||||||
backgroundImage: 'none',
|
borderRadius: d3roRadius.card,
|
||||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
backgroundColor: d3roPalette.bg.elevated,
|
||||||
boxShadow: d3roShadow.chassis,
|
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 }}>
|
{isSuccess ? (
|
||||||
{/* Step 0: 환영 */}
|
<CheckCircleOutlineIcon sx={{ color: colorSuccess }} />
|
||||||
{step === 0 && (
|
) : isFailed ? (
|
||||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
<ErrorOutlineIcon sx={{ color: colorDanger }} />
|
||||||
<Led color="amber" pulse size={16} />
|
) : (
|
||||||
|
<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
|
<Typography
|
||||||
sx={{
|
sx={{ ...typoSx('meta'), color: d3roPalette.text.secondary, mt: 1 }}
|
||||||
fontFamily: d3roFontMono,
|
|
||||||
fontSize: '24px',
|
|
||||||
fontWeight: 300,
|
|
||||||
color: d3roPalette.accent.amber,
|
|
||||||
mt: 3,
|
|
||||||
mb: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
D3RO-VOICE
|
{t('onboarding.status', { status })}
|
||||||
</Typography>
|
</Typography>
|
||||||
<Typography sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
)}
|
||||||
{t('onboarding.welcome.desc')}
|
</Box>
|
||||||
</Typography>
|
)}
|
||||||
<Button variant="contained" onClick={() => setStep(1)} fullWidth>
|
|
||||||
{t('onboarding.welcome.start')}
|
|
||||||
</Button>
|
|
||||||
</Box>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 1: 마이크 */}
|
{isSuccess && (
|
||||||
{step === 1 && (
|
<Typography sx={{ ...typoSx('body'), color: colorSuccess, mt: 2 }}>
|
||||||
<Box>
|
{t('onboarding.success')}
|
||||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
</Typography>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 2: 핫키 */}
|
{isFailed && (
|
||||||
{step === 2 && (
|
<Typography sx={{ ...typoSx('body'), color: colorDanger, mt: 2 }}>
|
||||||
<Box>
|
{t('onboarding.failed', { message: errorMsg })}
|
||||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
</Typography>
|
||||||
<KeyboardIcon sx={{ color: d3roPalette.accent.amber }} />
|
)}
|
||||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
</DialogContent>
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 3: Ollama 설치 */}
|
<DialogActions sx={{ px: 3, py: 2, gap: 1 }}>
|
||||||
{step === 3 && (
|
{phase === 'prompt' && (
|
||||||
<Box>
|
<>
|
||||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
|
||||||
<Led color="amber" size={12} />
|
{t('onboarding.cancel')}
|
||||||
<Typography sx={{ fontFamily: d3roFontMono, fontWeight: 700, fontSize: '14px' }}>
|
</Button>
|
||||||
{t('onboarding.ollama.title')}
|
<Button
|
||||||
</Typography>
|
onClick={handleDownload}
|
||||||
</Stack>
|
variant="contained"
|
||||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 2 }}>
|
sx={{ backgroundColor: colorAccent }}
|
||||||
{t('onboarding.ollama.desc')}
|
>
|
||||||
</Typography>
|
{t('onboarding.download')}
|
||||||
<Button
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 4: Cloud Sync (선택) */}
|
{isDownloading && (
|
||||||
{step === 4 && (
|
<Button disabled sx={{ color: d3roPalette.text.disabled }}>
|
||||||
<Box>
|
{t('onboarding.downloading')}
|
||||||
<Stack direction="row" alignItems="center" gap={1} mb={3}>
|
</Button>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Step 5: 완료 */}
|
{isSuccess && (
|
||||||
{step === 5 && (
|
<Button
|
||||||
<Box sx={{ textAlign: 'center', py: 3 }}>
|
onClick={handleClose}
|
||||||
<CheckCircleIcon sx={{ fontSize: 48, color: d3roPalette.tag.green, mb: 2 }} />
|
variant="contained"
|
||||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '18px', fontWeight: 700, mb: 1 }}>
|
sx={{ backgroundColor: colorSuccess }}
|
||||||
{t('onboarding.done.title')}
|
>
|
||||||
</Typography>
|
{t('onboarding.close')}
|
||||||
<Typography variant="body2" sx={{ color: d3roPalette.text.secondary, mb: 4 }}>
|
</Button>
|
||||||
{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>
|
|
||||||
|
|
||||||
<HotkeyRecordModal
|
{isFailed && (
|
||||||
open={hotkeyModalOpen}
|
<>
|
||||||
onClose={() => setHotkeyModalOpen(false)}
|
<Button onClick={handleClose} sx={{ color: d3roPalette.text.secondary }}>
|
||||||
onSave={handleHotkeySave}
|
{t('onboarding.close')}
|
||||||
currentBinding={hotkeyBinding}
|
</Button>
|
||||||
title={t('hotkey.dictationTitle')}
|
<Button
|
||||||
/>
|
onClick={handleDownload}
|
||||||
</>
|
variant="contained"
|
||||||
|
sx={{ backgroundColor: colorAccent }}
|
||||||
|
>
|
||||||
|
{t('onboarding.retry')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,40 @@
|
||||||
# D3RO-VOICE 프로젝트 현황
|
# 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) ✅
|
## RN CLI 전환 (2026-04-13) ✅
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -613,5 +613,19 @@
|
||||||
"mobile.status.link": "PRECISION DATA LINK",
|
"mobile.status.link": "PRECISION DATA LINK",
|
||||||
|
|
||||||
"date.today": "TODAY",
|
"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."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -613,5 +613,19 @@
|
||||||
"mobile.status.link": "PRECISION DATA LINK",
|
"mobile.status.link": "PRECISION DATA LINK",
|
||||||
|
|
||||||
"date.today": "TODAY",
|
"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 서버가 실행되지 않았습니다. 잠시 후 다시 시도하세요."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue