diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 0d9f4e7..7f78a67 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -263,6 +263,8 @@ function notifyRenderer(channel: string, data?: Record): void { async function initLLMPolling(): Promise { const llm = getLocalLLMService() + // 설치되어 있는데 꺼져 있으면 자동 실행 (detached). 폴링이 준비 완료를 감지한다. + await llm.ensureRunning() llm.startPolling() } diff --git a/src/main/services/LocalLLMService.ts b/src/main/services/LocalLLMService.ts index 5f307d5..58cdb3d 100644 --- a/src/main/services/LocalLLMService.ts +++ b/src/main/services/LocalLLMService.ts @@ -3,6 +3,9 @@ // 설계서 01의 ILocalLLMService 구현. import { EventEmitter } from 'events' +import { spawn } from 'child_process' +import * as fs from 'fs' +import * as path from 'path' import { getLogger } from './LoggerService' import { configGet } from './ConfigService' import { D3ROError, ErrorCode } from '@shared/errors' @@ -101,6 +104,120 @@ class LocalLLMService extends EventEmitter { return this._state } + /** + * Ollama 서버가 실행 중인지 확인하고, 설치되어 있는데 실행 중이 아니면 자동 실행한다. + * + * 반환값: + * - 'running': 이미 실행 중 + * - 'starting': 바이너리를 찾아 detached 스폰 완료 (준비 확인은 폴링이 담당) + * - 'not-installed': Ollama 바이너리를 찾을 수 없음 + * - 'failed': 스폰 시도 실패 + */ + async ensureRunning(): Promise<'running' | 'starting' | 'not-installed' | 'failed'> { + if (await this._ping(1500)) { + logger.info('Ollama server already running') + return 'running' + } + + const binaryPath = await this._findOllamaBinary() + if (!binaryPath) { + logger.warn('Ollama binary not found — install from https://ollama.com') + return 'not-installed' + } + + logger.info(`Ollama not running, auto-starting from ${binaryPath}`) + + try { + const child = spawn(binaryPath, ['serve'], { + detached: true, + stdio: 'ignore', + windowsHide: true + }) + child.unref() + logger.info('Ollama serve spawned (detached) — polling will detect readiness') + return 'starting' + } catch (error) { + logger.error( + `Failed to spawn ollama serve: ${error instanceof Error ? error.message : String(error)}` + ) + return 'failed' + } + } + + /** + * Ollama /api/tags 엔드포인트로 가용성 핑. 지정 타임아웃 내 응답이 오면 true. + */ + private async _ping(timeoutMs: number): Promise { + const serverUrl = configGet('ollamaServerUrl') + try { + const response = await fetch(`${serverUrl}/api/tags`, { + signal: AbortSignal.timeout(timeoutMs) + }) + return response.ok + } catch { + return false + } + } + + /** + * 플랫폼별 기본 설치 경로 + PATH 탐색으로 ollama 바이너리 위치를 찾는다. + */ + private async _findOllamaBinary(): Promise { + const candidates: string[] = [] + + if (process.platform === 'win32') { + const localAppData = process.env.LOCALAPPDATA + if (localAppData) { + candidates.push(path.join(localAppData, 'Programs', 'Ollama', 'ollama.exe')) + } + const programFiles = process.env['ProgramFiles'] + if (programFiles) { + candidates.push(path.join(programFiles, 'Ollama', 'ollama.exe')) + } + } else if (process.platform === 'darwin') { + candidates.push('/usr/local/bin/ollama', '/opt/homebrew/bin/ollama') + } else { + candidates.push('/usr/local/bin/ollama', '/usr/bin/ollama') + } + + for (const candidate of candidates) { + try { + await fs.promises.access(candidate, fs.constants.X_OK) + return candidate + } catch { + // 다음 후보 시도 + } + } + + return await this._whichOllama() + } + + /** + * `where ollama` (win) / `which ollama` (mac/linux)로 PATH에서 바이너리를 찾는다. + */ + private _whichOllama(): Promise { + return new Promise((resolve) => { + const cmd = process.platform === 'win32' ? 'where' : 'which' + const proc = spawn(cmd, ['ollama'], { + stdio: ['ignore', 'pipe', 'ignore'], + windowsHide: true + }) + let out = '' + proc.stdout.on('data', (chunk: Buffer) => { + out += chunk.toString() + }) + proc.on('close', (code) => { + if (code === 0) { + const firstLine = out.split(/\r?\n/).find((line) => line.trim().length > 0) + resolve(firstLine ? firstLine.trim() : null) + } else { + resolve(null) + } + }) + proc.on('error', () => resolve(null)) + }) + } + /** * Ollama 가용성 폴링을 시작한다 (5초 간격). */