d3ro-voice/apps/desktop/src/main/services/LocalLLMService.ts
Yun Chan 1af3cf75c7
Some checks failed
deploy-site / deploy (push) Failing after 14m16s
fix(release): stop shipping native modules built for the wrong runtime
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.
2026-09-18 15:45:03 +09:00

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
}