feat: Ollama 미실행 시 설치 경로 탐색 후 자동 스폰

- LocalLLMService.ensureRunning(): /api/tags 핑 → 바이너리 탐색 → detached 스폰
- 플랫폼별 기본 설치 경로 + where/which PATH 폴백
- bootstrap initLLMPolling에서 startPolling 전에 호출
- 감지는 기존 5초 폴링이 담당, 블로킹 없음
This commit is contained in:
yunchan8804 2026-04-08 13:49:42 +09:00
parent c5eea6f7db
commit 3a160b9032
2 changed files with 119 additions and 0 deletions

View file

@ -263,6 +263,8 @@ function notifyRenderer(channel: string, data?: Record<string, unknown>): void {
async function initLLMPolling(): Promise<void> {
const llm = getLocalLLMService()
// 설치되어 있는데 꺼져 있으면 자동 실행 (detached). 폴링이 준비 완료를 감지한다.
await llm.ensureRunning()
llm.startPolling()
}

View file

@ -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<boolean> {
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<string | null> {
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<string | null> {
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 ).
*/