Some checks failed
deploy-site / deploy (push) Failing after 14m16s
The released installer could not start: it carried a better-sqlite3 build for the host Node runtime instead of Electron, so the app died immediately with a module version mismatch when it opened its database. Packaging now proves the Electron build of every runtime-sensitive native module before an installer or archive exists, and installers are produced only from that verified tree, so the mistake cannot pass silently. The release pipelines run the same check. The default local model also pointed at a retired model: a *.gguf name that Ollama cannot serve, while the settings, onboarding, and guide screens recommended an older model. All of them now use the model the service code already preferred.
853 lines
26 KiB
TypeScript
853 lines
26 KiB
TypeScript
// src/main/services/LocalLLMService.ts
|
|
// Ollama REST API를 통해 로컬 LLM과 상호작용한다.
|
|
// 설계서 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 '@d3ro/core/errors'
|
|
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@d3ro/core/types'
|
|
import { resolveSystemPrompt } from './llm-prompts'
|
|
import { getBundledOllamaPath } from '../utils/paths'
|
|
import { normalizeLoopbackUrl } from '../utils/loopback'
|
|
|
|
const logger = getLogger('LocalLLMService')
|
|
|
|
/** Ollama 서버 URL — localhost는 ::1로 해석되어 실패하므로 IPv4 루프백으로 정규화한다. */
|
|
export function getOllamaServerUrl(): string {
|
|
return normalizeLoopbackUrl(configGet('ollamaServerUrl') || 'http://127.0.0.1:11434')
|
|
}
|
|
|
|
// ============================================================
|
|
// 내부 타입
|
|
// ============================================================
|
|
|
|
const enum LLMState {
|
|
Unavailable = 'unavailable',
|
|
Available = 'available',
|
|
Generating = 'generating',
|
|
Error = 'error'
|
|
}
|
|
|
|
interface GenerateOptions {
|
|
model?: string
|
|
temperature?: number
|
|
maxTokens?: number
|
|
systemPrompt?: string
|
|
stream?: boolean
|
|
}
|
|
|
|
interface GenerateResult {
|
|
text: string
|
|
model: string
|
|
promptTokens: number
|
|
completionTokens: number
|
|
totalDuration: number
|
|
}
|
|
|
|
interface OllamaGenerateResponse {
|
|
model: string
|
|
response: string
|
|
done: boolean
|
|
total_duration?: number
|
|
prompt_eval_count?: number
|
|
eval_count?: number
|
|
}
|
|
|
|
interface OllamaTagsResponse {
|
|
models: Array<{
|
|
name: string
|
|
size: number
|
|
parameter_size: string
|
|
quantization_level: string
|
|
modified_at: string
|
|
}>
|
|
}
|
|
|
|
interface LocalLLMEvents {
|
|
token: (payload: { token: string; done: boolean }) => void
|
|
complete: (payload: { result: GenerateResult }) => void
|
|
'availability-changed': (payload: { available: boolean }) => void
|
|
'pull-progress': (payload: unknown) => void
|
|
error: (payload: { error: D3ROError }) => void
|
|
}
|
|
|
|
// ============================================================
|
|
// 시스템 프롬프트 (설계서 Phase 4 참조)
|
|
// ============================================================
|
|
|
|
// 기본 권장 모델은 `gemma4:e4b` (non-reasoning). 기본값으로 thinking mode가
|
|
// 꺼져 있어 추가 토큰이 필요 없지만, 사용자가 수동으로 qwen3/deepseek-r1 등
|
|
// reasoning 모델로 교체했을 때를 대비한 2중 방어:
|
|
// (1) 아래 `/no_think` 시스템 프롬프트 토큰 (qwen3 계열 전용 힌트, 타 모델은 무시)
|
|
// (2) Ollama 요청 body의 `think: false` 파라미터 (Ollama v0.20.0+)
|
|
// (3) `stripReasoningBlocks()` 출력 가드
|
|
const NO_THINK = '/no_think'
|
|
|
|
/**
|
|
* Reasoning model(qwen3, deepseek-r1 등)이 응답에 포함하는
|
|
* <think>...</think> 블록을 제거한다. /no_think 토큰을 무시하는
|
|
* 모델에서도 안전하게 동작하도록.
|
|
*/
|
|
function stripReasoningBlocks(text: string): string {
|
|
return text
|
|
.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
|
|
.replace(/<thinking>[\s\S]*?<\/thinking>\s*/gi, '')
|
|
.trim()
|
|
}
|
|
|
|
// ============================================================
|
|
// LocalLLMService
|
|
// ============================================================
|
|
|
|
class LocalLLMService extends EventEmitter {
|
|
private _state = LLMState.Unavailable
|
|
/** 모델별 진행 중 pull — 중복 요청은 기존 promise에 합류 */
|
|
private _pullInFlight = new Map<string, Promise<void>>()
|
|
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
|
private _available = false
|
|
private _serverVersion: string | null = null
|
|
private _abortController: AbortController | null = null
|
|
private _disposed = false
|
|
|
|
get state(): LLMState {
|
|
return this._state
|
|
}
|
|
|
|
get serverVersion(): string | null {
|
|
return this._serverVersion
|
|
}
|
|
|
|
/**
|
|
* Ollama 서버를 명시적으로 실행하거나 재확인한다.
|
|
*/
|
|
async startServer(): Promise<'running' | 'starting' | 'not-installed' | 'failed'> {
|
|
const result = await this.ensureRunning()
|
|
if (result === 'starting' || result === 'running') {
|
|
// 1초 뒤 빠른 재확인
|
|
setTimeout(() => this._checkAvailability(), 1000)
|
|
setTimeout(() => this._checkAvailability(), 3000)
|
|
}
|
|
return result
|
|
}
|
|
|
|
/**
|
|
* 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/version 또는 /api/tags 엔드포인트로 가용성 핑. 지정 타임아웃 내 응답이 오면 true.
|
|
*/
|
|
private async _ping(timeoutMs: number): Promise<boolean> {
|
|
const serverUrl = getOllamaServerUrl()
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/version`, {
|
|
signal: AbortSignal.timeout(timeoutMs)
|
|
})
|
|
if (response.ok) {
|
|
try {
|
|
const data = (await response.json()) as { version?: string }
|
|
if (data?.version) this._serverVersion = data.version
|
|
} catch {
|
|
// ignore json parse
|
|
}
|
|
return true
|
|
}
|
|
} catch {
|
|
// fallback to /api/tags
|
|
}
|
|
|
|
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[] = []
|
|
|
|
// 1순위: 설치 파일에 번들된 ollama
|
|
const bundled = getBundledOllamaPath()
|
|
if (bundled) {
|
|
candidates.push(bundled)
|
|
}
|
|
|
|
if (process.platform === 'win32') {
|
|
const localAppData = process.env.LOCALAPPDATA
|
|
if (localAppData) {
|
|
candidates.push(path.join(localAppData, 'Programs', 'Ollama', 'ollama.exe'))
|
|
candidates.push(path.join(localAppData, 'Ollama', 'ollama.exe'))
|
|
candidates.push(path.join(localAppData, 'Programs', 'Ollama', 'ollama app.exe'))
|
|
}
|
|
const programFiles = process.env['ProgramFiles']
|
|
if (programFiles) {
|
|
candidates.push(path.join(programFiles, 'Ollama', 'ollama.exe'))
|
|
}
|
|
const programFilesX86 = process.env['ProgramFiles(x86)']
|
|
if (programFilesX86) {
|
|
candidates.push(path.join(programFilesX86, 'Ollama', 'ollama.exe'))
|
|
}
|
|
const userProfile = process.env.USERPROFILE
|
|
if (userProfile) {
|
|
candidates.push(path.join(userProfile, 'AppData', 'Local', 'Programs', 'Ollama', 'ollama.exe'))
|
|
candidates.push(path.join(userProfile, 'AppData', 'Local', 'Ollama', 'ollama.exe'))
|
|
}
|
|
} else if (process.platform === 'darwin') {
|
|
candidates.push(
|
|
'/Applications/Ollama.app/Contents/Resources/ollama',
|
|
'/usr/local/bin/ollama',
|
|
'/opt/homebrew/bin/ollama'
|
|
)
|
|
} else {
|
|
candidates.push(
|
|
'/usr/local/bin/ollama',
|
|
'/usr/bin/ollama',
|
|
'/opt/ollama/bin/ollama'
|
|
)
|
|
}
|
|
|
|
for (const candidate of candidates) {
|
|
try {
|
|
await fs.promises.access(candidate, fs.constants.F_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))
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 명시적 연결 테스트 및 최신 상태 조회
|
|
*/
|
|
async checkConnection(): Promise<{ available: boolean; version: string | null; models: LLMModel[] }> {
|
|
await this._checkAvailability()
|
|
const models = this._available ? await this.getModels() : []
|
|
return {
|
|
available: this._available,
|
|
version: this._serverVersion,
|
|
models
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ollama 가용성 폴링을 시작한다 (5초 간격).
|
|
*/
|
|
startPolling(): void {
|
|
this._checkAvailability()
|
|
this._pollInterval = setInterval(() => {
|
|
if (this._state !== LLMState.Generating) {
|
|
this._checkAvailability()
|
|
}
|
|
}, 5000)
|
|
logger.info('Ollama availability polling started')
|
|
}
|
|
|
|
stopPolling(): void {
|
|
if (this._pollInterval) {
|
|
clearInterval(this._pollInterval)
|
|
this._pollInterval = null
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 비스트리밍 텍스트 생성.
|
|
*/
|
|
async generate(prompt: string, options?: GenerateOptions): Promise<GenerateResult> {
|
|
if (!this._available) {
|
|
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama 서버에 연결할 수 없습니다')
|
|
}
|
|
|
|
const serverUrl = getOllamaServerUrl()
|
|
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
|
|
|
this._state = LLMState.Generating
|
|
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
prompt,
|
|
system: options?.systemPrompt,
|
|
stream: false,
|
|
// Ollama v0.20+ think 파라미터: reasoning 모델에서 thinking 토큰 생성 중단.
|
|
// gemma4/llama3.2 등 non-reasoning 모델에서는 무시됨.
|
|
think: false,
|
|
options: {
|
|
temperature: options?.temperature ?? 0.3,
|
|
num_predict: options?.maxTokens ?? 2048
|
|
}
|
|
}),
|
|
signal: AbortSignal.timeout(120000)
|
|
})
|
|
|
|
if (!response.ok) {
|
|
throw new D3ROError(
|
|
ErrorCode.LLMProcessingFailed,
|
|
`Ollama responded with ${response.status}: ${response.statusText}`
|
|
)
|
|
}
|
|
|
|
const data = (await response.json()) as OllamaGenerateResponse
|
|
|
|
const result: GenerateResult = {
|
|
text: data.response,
|
|
model: data.model,
|
|
promptTokens: data.prompt_eval_count ?? 0,
|
|
completionTokens: data.eval_count ?? 0,
|
|
totalDuration: data.total_duration ? data.total_duration / 1e6 : 0
|
|
}
|
|
|
|
this._state = LLMState.Available
|
|
this.emit('complete', { result })
|
|
return result
|
|
} catch (error) {
|
|
this._state = LLMState.Available
|
|
if (error instanceof D3ROError) throw error
|
|
throw new D3ROError(
|
|
ErrorCode.LLMProcessingFailed,
|
|
`LLM generation failed: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 스트리밍 텍스트 생성. NDJSON 파싱.
|
|
* 반환된 AbortController로 취소 가능.
|
|
*/
|
|
async *streamGenerate(
|
|
prompt: string,
|
|
options?: Omit<GenerateOptions, 'stream'>
|
|
): AsyncGenerator<string, GenerateResult> {
|
|
if (!this._available) {
|
|
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama 서버에 연결할 수 없습니다')
|
|
}
|
|
|
|
const serverUrl = getOllamaServerUrl()
|
|
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
|
|
|
this._state = LLMState.Generating
|
|
this._abortController = new AbortController()
|
|
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/generate`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
prompt,
|
|
system: options?.systemPrompt,
|
|
stream: true,
|
|
think: false,
|
|
options: {
|
|
temperature: options?.temperature ?? 0.3,
|
|
num_predict: options?.maxTokens ?? 2048
|
|
}
|
|
}),
|
|
signal: this._abortController.signal
|
|
})
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new D3ROError(
|
|
ErrorCode.LLMProcessingFailed,
|
|
`Ollama responded with ${response.status}`
|
|
)
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
let fullText = ''
|
|
let lastChunk: OllamaGenerateResponse | null = null
|
|
|
|
while (true) {
|
|
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) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const chunk = JSON.parse(line) as OllamaGenerateResponse
|
|
fullText += chunk.response
|
|
this.emit('token', { token: chunk.response, done: chunk.done })
|
|
yield chunk.response
|
|
|
|
if (chunk.done) {
|
|
lastChunk = chunk
|
|
}
|
|
} catch {
|
|
logger.warn(`Failed to parse NDJSON line: ${line.substring(0, 100)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
this._state = LLMState.Available
|
|
this._abortController = null
|
|
|
|
const result: GenerateResult = {
|
|
text: fullText,
|
|
model: lastChunk?.model ?? model,
|
|
promptTokens: lastChunk?.prompt_eval_count ?? 0,
|
|
completionTokens: lastChunk?.eval_count ?? 0,
|
|
totalDuration: lastChunk?.total_duration ? lastChunk.total_duration / 1e6 : 0
|
|
}
|
|
|
|
this.emit('complete', { result })
|
|
return result
|
|
} catch (error) {
|
|
this._state = LLMState.Available
|
|
this._abortController = null
|
|
if (error instanceof D3ROError) throw error
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
throw new D3ROError(ErrorCode.LLMProcessingCancelled, 'LLM generation cancelled')
|
|
}
|
|
throw new D3ROError(
|
|
ErrorCode.LLMProcessingFailed,
|
|
`LLM streaming failed: ${error instanceof Error ? error.message : String(error)}`
|
|
)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 텍스트를 LLM 액션에 따라 처리한다.
|
|
*/
|
|
async processText(
|
|
text: string,
|
|
action: LLMAction,
|
|
targetLanguage?: string,
|
|
customPrompt?: string
|
|
): Promise<string> {
|
|
// Phase 11: LLM 처리 쿼터 체크
|
|
try {
|
|
const { getLicenseService } = await import('./LicenseService')
|
|
const { Feature } = await import('@d3ro/core/types')
|
|
const license = getLicenseService()
|
|
const access = license.canUse(Feature.LLM_PROCESS)
|
|
if (!access.allowed) {
|
|
license.promptUpgrade(Feature.LLM_PROCESS, access.reason === 'quota_exceeded' ? 'quota_exceeded' : 'tier_required')
|
|
throw new D3ROError(
|
|
access.reason === 'quota_exceeded' ? ErrorCode.QuotaExceeded : ErrorCode.TierRequired,
|
|
`LLM process blocked: ${access.reason}`,
|
|
)
|
|
}
|
|
license.consumeQuota(Feature.LLM_PROCESS)
|
|
} catch {
|
|
// LicenseService 미초기화 시 허용
|
|
}
|
|
|
|
const basePrompt = resolveSystemPrompt(action, targetLanguage, customPrompt)
|
|
const systemPrompt = `${NO_THINK}\n${basePrompt}`
|
|
|
|
const result = await this.generate(text, { systemPrompt })
|
|
const cleaned = stripReasoningBlocks(result.text)
|
|
// reasoning 블록 제거 후 빈 응답이면 원본 텍스트 폴백
|
|
// (모델이 thinking만 하고 출력은 안 한 경우 / 응답 파싱 실패 케이스)
|
|
if (cleaned.length === 0) {
|
|
throw new D3ROError(
|
|
ErrorCode.LLMProcessingFailed,
|
|
'Local LLM returned empty text after reasoning strip',
|
|
)
|
|
}
|
|
return cleaned
|
|
}
|
|
|
|
cancelGeneration(): void {
|
|
if (this._abortController) {
|
|
this._abortController.abort()
|
|
this._abortController = null
|
|
logger.info('LLM generation cancelled')
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ollama에 설치된 모델 목록을 조회한다.
|
|
*/
|
|
async getModels(): Promise<LLMModel[]> {
|
|
const serverUrl = getOllamaServerUrl()
|
|
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/tags`, {
|
|
signal: AbortSignal.timeout(5000)
|
|
})
|
|
|
|
if (!response.ok) return []
|
|
|
|
const data = (await response.json()) as OllamaTagsResponse
|
|
|
|
return data.models.map((m) => ({
|
|
id: m.name,
|
|
name: m.name,
|
|
sizeBytes: m.size,
|
|
parameterSize: m.parameter_size ?? '',
|
|
quantization: m.quantization_level ?? '',
|
|
modifiedAt: m.modified_at
|
|
}))
|
|
} catch {
|
|
return []
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Ollama /api/pull — 모델 다운로드. 진행률을 EventEmitter로 방출.
|
|
* 스트리밍 JSON 라인을 파싱해 각 chunk마다 'pull-progress' 이벤트 emit.
|
|
* 완료 시 resolve, 에러 시 reject.
|
|
*
|
|
* @param modelId 예: 'gemma4:e4b'
|
|
*/
|
|
async pullModel(modelId: string): Promise<void> {
|
|
// 동일 모델 동시 pull 방지 — 진행 중이면 같은 promise에 합류
|
|
const inFlight = this._pullInFlight.get(modelId)
|
|
if (inFlight) {
|
|
logger.info(`Pull 진행 중 재요청 — 기존 작업에 합류: ${modelId}`)
|
|
return inFlight
|
|
}
|
|
const task = this._doPullModel(modelId).finally(() => {
|
|
this._pullInFlight.delete(modelId)
|
|
})
|
|
this._pullInFlight.set(modelId, task)
|
|
return task
|
|
}
|
|
|
|
private async _doPullModel(modelId: string): Promise<void> {
|
|
const serverUrl = getOllamaServerUrl()
|
|
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 = (
|
|
this._available
|
|
? this._state === LLMState.Generating
|
|
? 'connecting'
|
|
: 'connected'
|
|
: 'disconnected'
|
|
) as LLMConnectionState
|
|
|
|
return {
|
|
connectionState,
|
|
serverUrl: getOllamaServerUrl(),
|
|
activeModel: configGet('llmModelId') || 'gemma4:e4b',
|
|
serverVersion: this._serverVersion
|
|
}
|
|
}
|
|
|
|
isAvailable(): boolean {
|
|
return this._available
|
|
}
|
|
|
|
/**
|
|
* Ollama /api/chat 스트리밍 대화.
|
|
* messages 배열로 대화 히스토리를 전달한다.
|
|
* 각 토큰마다 yield, 완료 시 전체 응답 텍스트를 return.
|
|
*/
|
|
async *chatStream(
|
|
messages: Array<{ role: string; content: string }>,
|
|
options?: { model?: string; temperature?: number },
|
|
): AsyncGenerator<string, string> {
|
|
if (!this._available) {
|
|
throw new D3ROError(ErrorCode.LLMServerUnreachable, 'Ollama server not available')
|
|
}
|
|
|
|
const serverUrl = getOllamaServerUrl()
|
|
const model = options?.model ?? configGet('llmModelId') ?? 'gemma4:e4b'
|
|
|
|
this._abortController = new AbortController()
|
|
this._state = LLMState.Generating
|
|
|
|
try {
|
|
const response = await fetch(`${serverUrl}/api/chat`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
model,
|
|
messages,
|
|
stream: true,
|
|
think: false,
|
|
options: {
|
|
temperature: options?.temperature ?? 0.7,
|
|
},
|
|
}),
|
|
signal: this._abortController.signal,
|
|
})
|
|
|
|
if (!response.ok || !response.body) {
|
|
throw new D3ROError(ErrorCode.LLMProcessingFailed, `Chat API error: ${response.status}`)
|
|
}
|
|
|
|
const reader = response.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buffer = ''
|
|
let accumulated = ''
|
|
|
|
while (true) {
|
|
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) {
|
|
if (!line.trim()) continue
|
|
try {
|
|
const chunk = JSON.parse(line) as { message?: { content: string }; done: boolean }
|
|
if (chunk.message?.content) {
|
|
accumulated += chunk.message.content
|
|
yield chunk.message.content
|
|
}
|
|
if (chunk.done) {
|
|
return accumulated
|
|
}
|
|
} catch {
|
|
// 불완전 JSON 무시
|
|
}
|
|
}
|
|
}
|
|
|
|
return accumulated
|
|
} finally {
|
|
this._state = this._available ? LLMState.Available : LLMState.Unavailable
|
|
this._abortController = null
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this._disposed = true
|
|
this.stopPolling()
|
|
this.cancelGeneration()
|
|
this.removeAllListeners()
|
|
logger.info('LocalLLMService disposed')
|
|
}
|
|
|
|
// ── 가용성 체크 ────────────────────────────────────────
|
|
|
|
private async _checkAvailability(): Promise<void> {
|
|
if (this._disposed) return
|
|
const serverUrl = getOllamaServerUrl()
|
|
|
|
let isOk = false
|
|
let detectedVersion: string | null = null
|
|
|
|
try {
|
|
const verRes = await fetch(`${serverUrl}/api/version`, {
|
|
signal: AbortSignal.timeout(2000)
|
|
})
|
|
if (verRes.ok) {
|
|
isOk = true
|
|
try {
|
|
const data = (await verRes.json()) as { version?: string }
|
|
if (data?.version) detectedVersion = data.version
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
|
|
if (!isOk) {
|
|
try {
|
|
const tagsRes = await fetch(`${serverUrl}/api/tags`, {
|
|
signal: AbortSignal.timeout(2000)
|
|
})
|
|
if (tagsRes.ok) {
|
|
isOk = true
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
|
|
const wasAvailable = this._available
|
|
this._available = isOk
|
|
if (detectedVersion) this._serverVersion = detectedVersion
|
|
|
|
if (!wasAvailable && this._available) {
|
|
this._state = LLMState.Available
|
|
this.emit('availability-changed', { available: true })
|
|
logger.info(`Ollama server connected (version: ${this._serverVersion ?? 'active'})`)
|
|
} else if (wasAvailable && !this._available) {
|
|
this._state = LLMState.Unavailable
|
|
this._serverVersion = null
|
|
this.emit('availability-changed', { available: false })
|
|
logger.warn('Ollama server disconnected')
|
|
}
|
|
}
|
|
|
|
// ── EventEmitter 타입 오버라이드 ───────────────────────
|
|
|
|
override on<K extends keyof LocalLLMEvents>(event: K, listener: LocalLLMEvents[K]): this {
|
|
return super.on(event, listener)
|
|
}
|
|
|
|
override off<K extends keyof LocalLLMEvents>(event: K, listener: LocalLLMEvents[K]): this {
|
|
return super.off(event, listener)
|
|
}
|
|
|
|
override emit<K extends keyof LocalLLMEvents>(
|
|
event: K,
|
|
...args: Parameters<LocalLLMEvents[K]>
|
|
): boolean {
|
|
return super.emit(event, ...args)
|
|
}
|
|
}
|
|
|
|
// ── 싱글톤 ─────────────────────────────────────────────
|
|
|
|
let instance: LocalLLMService | null = null
|
|
|
|
export function getLocalLLMService(): LocalLLMService {
|
|
if (!instance) {
|
|
instance = new LocalLLMService()
|
|
}
|
|
return instance
|
|
}
|
|
|
|
/** bootstrap llm-polling 스텝 — Ollama 기동 + 가용성 폴링. */
|
|
export async function startLocalLLMAvailability(): Promise<void> {
|
|
const llm = getLocalLLMService()
|
|
await llm.ensureRunning()
|
|
llm.startPolling()
|
|
}
|
|
|
|
export function resetLocalLLMServiceForTests(): void {
|
|
if (instance) instance.removeAllListeners()
|
|
instance = null
|
|
}
|