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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
* 효과음 파일 경로.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue