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
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue