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

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