feat: Ollama 미실행 시 설치 경로 탐색 후 자동 스폰
- LocalLLMService.ensureRunning(): /api/tags 핑 → 바이너리 탐색 → detached 스폰 - 플랫폼별 기본 설치 경로 + where/which PATH 폴백 - bootstrap initLLMPolling에서 startPolling 전에 호출 - 감지는 기존 5초 폴링이 담당, 블로킹 없음
This commit is contained in:
parent
c5eea6f7db
commit
3a160b9032
2 changed files with 119 additions and 0 deletions
|
|
@ -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초 간격).
|
||||
*/
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue