Phase 4 구현: Ollama LLM 연동 (다듬기, 번역, 스트리밍)
- LocalLLMService: Ollama REST API, 스트리밍 NDJSON 파싱, 5초 가용성 폴링 - 시스템 프롬프트: refine/translate/summarize/grammar/expand/custom 6개 액션 - VoiceModeService: 전사→LLM 후처리→텍스트 삽입, LLM 실패 시 원본 폴백 - LLM IPC 핸들러: status/models/process/cancel/serverUrl 8개 - StatusBar: Ollama 연결 상태 + 활성 모델 표시 - Preload: llm API 섹션 추가 - Bootstrap: llm-polling 초기화 단계 추가
This commit is contained in:
parent
517210af2f
commit
4a5cf6c819
10 changed files with 663 additions and 9 deletions
16
CLAUDE.md
16
CLAUDE.md
|
|
@ -77,10 +77,18 @@ npm run typecheck # tsc --noEmit
|
||||||
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션
|
||||||
|
|
||||||
## 현재 상태
|
## 현재 상태
|
||||||
Phase: 3 완료
|
Phase: 4 완료
|
||||||
마지막 완료: Phase 3 — TextInsertService + RecordingTip/ResultPopup 팝업 + Settings 모달
|
마지막 완료: Phase 4 — Ollama LLM 연동 (텍스트 다듬기, 번역, 스트리밍)
|
||||||
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능)
|
다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 또는 Phase 5 — TTS + DB
|
||||||
차단 이슈: SoX 미설치 시 AudioCaptureService 동작 불가, @nut-tree/nut-js → @nut-tree-fork/nut-js 포크 사용
|
차단 이슈: SoX 미설치, @nut-tree-fork/nut-js 포크 사용
|
||||||
|
|
||||||
|
### Phase 4 구현 내용
|
||||||
|
- LocalLLMService: Ollama REST API 연동, 스트리밍 NDJSON 파싱, 가용성 폴링(5초)
|
||||||
|
- 시스템 프롬프트: refine/translate/summarize/grammar/expand/custom 6개 액션
|
||||||
|
- VoiceModeService LLM 연동: 전사→LLM 후처리→텍스트 삽입, LLM 실패 시 원본 폴백
|
||||||
|
- LLM IPC 핸들러: llm:getStatus/getModels/setModel/process/cancelProcess/getServerUrl/setServerUrl
|
||||||
|
- StatusBar: Ollama 연결 상태 + 활성 모델 표시
|
||||||
|
- Bootstrap: LLM 가용성 폴링 초기화 단계 추가
|
||||||
|
|
||||||
### Phase 3 구현 내용
|
### Phase 3 구현 내용
|
||||||
- TextInsertService: clipboard save→set→Ctrl+V→restore (@nut-tree-fork/nut-js, lazy dynamic import)
|
- TextInsertService: clipboard save→set→Ctrl+V→restore (@nut-tree-fork/nut-js, lazy dynamic import)
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { initLoggerService, getLogger } from './services/LoggerService'
|
||||||
import { initConfigService } from './services/ConfigService'
|
import { initConfigService } from './services/ConfigService'
|
||||||
import { getHotkeyService } from './services/HotkeyService'
|
import { getHotkeyService } from './services/HotkeyService'
|
||||||
import { getVoiceModeService } from './services/VoiceModeService'
|
import { getVoiceModeService } from './services/VoiceModeService'
|
||||||
|
import { getLocalLLMService } from './services/LocalLLMService'
|
||||||
import {
|
import {
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
preloadPopupWindows,
|
preloadPopupWindows,
|
||||||
|
|
@ -34,7 +35,8 @@ export async function bootstrap(): Promise<void> {
|
||||||
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
{ name: 'ipc-handlers', critical: true, fn: initIpcHandlers },
|
||||||
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
{ name: 'popup-preload', critical: false, fn: initPopupWindows },
|
||||||
{ name: 'hotkey', critical: false, fn: initHotkey },
|
{ name: 'hotkey', critical: false, fn: initHotkey },
|
||||||
{ name: 'voice-mode', critical: false, fn: initVoiceMode }
|
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
|
||||||
|
{ name: 'llm-polling', critical: false, fn: initLLMPolling }
|
||||||
]
|
]
|
||||||
|
|
||||||
for (const step of steps) {
|
for (const step of steps) {
|
||||||
|
|
@ -120,3 +122,8 @@ async function initVoiceMode(): Promise<void> {
|
||||||
setTimeout(() => hideRecordingTip(), 3000)
|
setTimeout(() => hideRecordingTip(), 3000)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function initLLMPolling(): Promise<void> {
|
||||||
|
const llm = getLocalLLMService()
|
||||||
|
llm.startPolling()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { registerSystemHandlers } from './system-handlers'
|
||||||
import { registerVoiceHandlers } from './voice-handlers'
|
import { registerVoiceHandlers } from './voice-handlers'
|
||||||
import { registerSTTHandlers } from './stt-handlers'
|
import { registerSTTHandlers } from './stt-handlers'
|
||||||
import { registerHotkeyHandlers } from './hotkey-handlers'
|
import { registerHotkeyHandlers } from './hotkey-handlers'
|
||||||
|
import { registerLLMHandlers } from './llm-handlers'
|
||||||
import { getLogger } from '../services/LoggerService'
|
import { getLogger } from '../services/LoggerService'
|
||||||
|
|
||||||
const logger = getLogger('ipc')
|
const logger = getLogger('ipc')
|
||||||
|
|
@ -19,5 +20,6 @@ export function registerAllIpcHandlers(): void {
|
||||||
registerVoiceHandlers()
|
registerVoiceHandlers()
|
||||||
registerSTTHandlers()
|
registerSTTHandlers()
|
||||||
registerHotkeyHandlers()
|
registerHotkeyHandlers()
|
||||||
|
registerLLMHandlers()
|
||||||
logger.info('All IPC handlers registered')
|
logger.info('All IPC handlers registered')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
68
src/main/ipc/llm-handlers.ts
Normal file
68
src/main/ipc/llm-handlers.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
// src/main/ipc/llm-handlers.ts
|
||||||
|
|
||||||
|
import { ipcMain } from 'electron'
|
||||||
|
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||||
|
import { ipcSuccess, ipcError, ErrorCode } from '@shared/errors'
|
||||||
|
import { getLocalLLMService } from '../services/LocalLLMService'
|
||||||
|
import { configGet, configSet } from '../services/ConfigService'
|
||||||
|
import type { SetLLMModelParams, SetServerUrlParams, LLMProcessParams } from '@shared/types'
|
||||||
|
|
||||||
|
export function registerLLMHandlers(): void {
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.GET_STATUS, async () => {
|
||||||
|
return ipcSuccess(getLocalLLMService().getStatus())
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.GET_MODELS, async () => {
|
||||||
|
try {
|
||||||
|
const models = await getLocalLLMService().getModels()
|
||||||
|
return ipcSuccess(models)
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.LLMServerUnreachable, 'Failed to get models')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL, async () => {
|
||||||
|
return ipcSuccess(configGet('llmModelId'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.SET_MODEL, async (_event, params: SetLLMModelParams) => {
|
||||||
|
configSet('llmModelId', params.modelId)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.PROCESS, async (_event, params: LLMProcessParams) => {
|
||||||
|
try {
|
||||||
|
const llm = getLocalLLMService()
|
||||||
|
const start = performance.now()
|
||||||
|
const processedText = await llm.processText(
|
||||||
|
params.text,
|
||||||
|
params.action,
|
||||||
|
params.targetLanguage,
|
||||||
|
params.customPrompt
|
||||||
|
)
|
||||||
|
return ipcSuccess({
|
||||||
|
originalText: params.text,
|
||||||
|
processedText,
|
||||||
|
action: params.action,
|
||||||
|
processingTimeMs: Math.round(performance.now() - start),
|
||||||
|
tokenCount: 0
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
return ipcError(ErrorCode.LLMProcessingFailed, 'LLM processing failed')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.CANCEL_PROCESS, async () => {
|
||||||
|
getLocalLLMService().cancelGeneration()
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.GET_SERVER_URL, async () => {
|
||||||
|
return ipcSuccess(configGet('ollamaServerUrl'))
|
||||||
|
})
|
||||||
|
|
||||||
|
ipcMain.handle(IPC_CHANNELS.LLM.SET_SERVER_URL, async (_event, params: SetServerUrlParams) => {
|
||||||
|
configSet('ollamaServerUrl', params.url)
|
||||||
|
return ipcSuccess(undefined)
|
||||||
|
})
|
||||||
|
}
|
||||||
433
src/main/services/LocalLLMService.ts
Normal file
433
src/main/services/LocalLLMService.ts
Normal file
|
|
@ -0,0 +1,433 @@
|
||||||
|
// src/main/services/LocalLLMService.ts
|
||||||
|
// Ollama REST API를 통해 로컬 LLM과 상호작용한다.
|
||||||
|
// 설계서 01의 ILocalLLMService 구현.
|
||||||
|
|
||||||
|
import { EventEmitter } from 'events'
|
||||||
|
import { getLogger } from './LoggerService'
|
||||||
|
import { configGet } from './ConfigService'
|
||||||
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
|
import type { LLMStatus, LLMModel, LLMAction, LLMConnectionState } from '@shared/types'
|
||||||
|
|
||||||
|
const logger = getLogger('LocalLLMService')
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 내부 타입
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
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
|
||||||
|
error: (payload: { error: D3ROError }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// 시스템 프롬프트 (설계서 Phase 4 참조)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
const SYSTEM_PROMPTS: Record<string, string> = {
|
||||||
|
refine: `다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요.
|
||||||
|
원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요.
|
||||||
|
다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.`,
|
||||||
|
|
||||||
|
translate: `다음 텍스트를 {{targetLanguage}}로 번역해주세요.
|
||||||
|
자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.`,
|
||||||
|
|
||||||
|
summarize: `다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요.
|
||||||
|
요약문만 출력하세요.`,
|
||||||
|
|
||||||
|
grammar: `다음 텍스트의 문법 오류만 수정해주세요.
|
||||||
|
원래 의미와 톤을 유지하면서 문법 오류만 수정하세요.
|
||||||
|
수정된 텍스트만 출력하세요.`,
|
||||||
|
|
||||||
|
expand: `다음 텍스트를 더 자세하고 풍부하게 확장해주세요.
|
||||||
|
확장된 텍스트만 출력하세요.`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LocalLLMService
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
class LocalLLMService extends EventEmitter {
|
||||||
|
private _state = LLMState.Unavailable
|
||||||
|
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
private _available = false
|
||||||
|
private _abortController: AbortController | null = null
|
||||||
|
private _disposed = false
|
||||||
|
|
||||||
|
get state(): LLMState {
|
||||||
|
return this._state
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 = configGet('ollamaServerUrl')
|
||||||
|
const model = options?.model ?? configGet('llmModelId') ?? 'qwen3:4b'
|
||||||
|
|
||||||
|
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,
|
||||||
|
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 = configGet('ollamaServerUrl')
|
||||||
|
const model = options?.model ?? configGet('llmModelId') ?? 'qwen3:4b'
|
||||||
|
|
||||||
|
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,
|
||||||
|
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> {
|
||||||
|
let systemPrompt: string
|
||||||
|
|
||||||
|
if (action === 'custom' && customPrompt) {
|
||||||
|
systemPrompt = customPrompt
|
||||||
|
} else if (action === 'translate') {
|
||||||
|
systemPrompt = SYSTEM_PROMPTS.translate.replace(
|
||||||
|
'{{targetLanguage}}',
|
||||||
|
targetLanguage ?? 'English'
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
systemPrompt = SYSTEM_PROMPTS[action] ?? SYSTEM_PROMPTS.refine
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.generate(text, { systemPrompt })
|
||||||
|
return result.text.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
cancelGeneration(): void {
|
||||||
|
if (this._abortController) {
|
||||||
|
this._abortController.abort()
|
||||||
|
this._abortController = null
|
||||||
|
logger.info('LLM generation cancelled')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ollama에 설치된 모델 목록을 조회한다.
|
||||||
|
*/
|
||||||
|
async getModels(): Promise<LLMModel[]> {
|
||||||
|
const serverUrl = configGet('ollamaServerUrl')
|
||||||
|
|
||||||
|
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 []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus(): LLMStatus {
|
||||||
|
const connectionState: LLMConnectionState = this._available
|
||||||
|
? this._state === LLMState.Generating
|
||||||
|
? 'connecting'
|
||||||
|
: 'connected'
|
||||||
|
: 'disconnected'
|
||||||
|
|
||||||
|
return {
|
||||||
|
connectionState,
|
||||||
|
serverUrl: configGet('ollamaServerUrl'),
|
||||||
|
activeModel: configGet('llmModelId'),
|
||||||
|
serverVersion: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isAvailable(): boolean {
|
||||||
|
return this._available
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = configGet('ollamaServerUrl')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${serverUrl}/api/tags`, {
|
||||||
|
signal: AbortSignal.timeout(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
const wasAvailable = this._available
|
||||||
|
this._available = response.ok
|
||||||
|
|
||||||
|
if (!wasAvailable && this._available) {
|
||||||
|
this._state = LLMState.Available
|
||||||
|
this.emit('availability-changed', { available: true })
|
||||||
|
logger.info('Ollama server connected')
|
||||||
|
} else if (wasAvailable && !this._available) {
|
||||||
|
this._state = LLMState.Unavailable
|
||||||
|
this.emit('availability-changed', { available: false })
|
||||||
|
logger.warn('Ollama server disconnected')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (this._available) {
|
||||||
|
this._available = false
|
||||||
|
this._state = LLMState.Unavailable
|
||||||
|
this.emit('availability-changed', { available: false })
|
||||||
|
logger.warn('Ollama server unreachable')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ import { getHotkeyService } from './HotkeyService'
|
||||||
import type { HotkeyConfig } from './HotkeyService'
|
import type { HotkeyConfig } from './HotkeyService'
|
||||||
import { configGet } from './ConfigService'
|
import { configGet } from './ConfigService'
|
||||||
import { getTextInsertService } from './TextInsertService'
|
import { getTextInsertService } from './TextInsertService'
|
||||||
|
import { getLocalLLMService } from './LocalLLMService'
|
||||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||||
import { TIMING } from '@shared/constants'
|
import { TIMING } from '@shared/constants'
|
||||||
import { RecognitionState, AudioState } from '@shared/types'
|
import { RecognitionState, AudioState } from '@shared/types'
|
||||||
|
|
@ -407,8 +408,14 @@ class VoiceModeService extends EventEmitter {
|
||||||
|
|
||||||
this.emit('transcription-update', { text: result.text, isFinal: true })
|
this.emit('transcription-update', { text: result.text, isFinal: true })
|
||||||
|
|
||||||
// Phase 2: LLM 후처리 없이 바로 완료
|
// LLM 후처리
|
||||||
this._completeSession(result.text)
|
const llmAction = configGet('defaultLLMAction')
|
||||||
|
if (llmAction !== 'refine' || !getLocalLLMService().isAvailable()) {
|
||||||
|
// LLM 미가용이거나 기본 액션이면 원본 텍스트로 완료
|
||||||
|
this._completeSession(result.text)
|
||||||
|
} else {
|
||||||
|
await this._processWithLLM(result.text)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (this._isInTerminalState()) return
|
if (this._isInTerminalState()) return
|
||||||
this._handleError(
|
this._handleError(
|
||||||
|
|
@ -420,6 +427,35 @@ class VoiceModeService extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── LLM 후처리 ─────────────────────────────────────────
|
||||||
|
|
||||||
|
private async _processWithLLM(transcribedText: string): Promise<void> {
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
// RECOGNIZING 상태 유지 (UI에서 thinking으로 표시됨)
|
||||||
|
try {
|
||||||
|
const llm = getLocalLLMService()
|
||||||
|
const action = configGet('defaultLLMAction')
|
||||||
|
|
||||||
|
logger.info(`Processing with LLM (action: ${action})`)
|
||||||
|
|
||||||
|
const processedText = await llm.processText(transcribedText, action)
|
||||||
|
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
|
||||||
|
if (this._session) {
|
||||||
|
this._session.processedText = processedText
|
||||||
|
}
|
||||||
|
|
||||||
|
this._completeSession(processedText)
|
||||||
|
} catch (error) {
|
||||||
|
if (this._isInTerminalState()) return
|
||||||
|
logger.warn(`LLM processing failed, using original text: ${error instanceof Error ? error.message : String(error)}`)
|
||||||
|
// LLM 실패 시 원본 텍스트로 폴백
|
||||||
|
this._completeSession(transcribedText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── 세션 완료/취소 ─────────────────────────────────────
|
// ── 세션 완료/취소 ─────────────────────────────────────
|
||||||
|
|
||||||
private async _completeSession(finalText: string): Promise<void> {
|
private async _completeSession(finalText: string): Promise<void> {
|
||||||
|
|
|
||||||
|
|
@ -25,3 +25,4 @@ export { getHotkeyService } from './HotkeyService'
|
||||||
export { getVoiceModeService } from './VoiceModeService'
|
export { getVoiceModeService } from './VoiceModeService'
|
||||||
export { getAudioCaptureService } from './AudioCaptureService'
|
export { getAudioCaptureService } from './AudioCaptureService'
|
||||||
export { getTextInsertService } from './TextInsertService'
|
export { getTextInsertService } from './TextInsertService'
|
||||||
|
export { getLocalLLMService } from './LocalLLMService'
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,14 @@ import type {
|
||||||
SetHotkeyParams,
|
SetHotkeyParams,
|
||||||
SetEnabledParams,
|
SetEnabledParams,
|
||||||
HotkeyTriggeredEvent,
|
HotkeyTriggeredEvent,
|
||||||
|
LLMStatus,
|
||||||
|
LLMModel,
|
||||||
|
LLMProcessParams,
|
||||||
|
LLMProcessResult,
|
||||||
|
SetLLMModelParams,
|
||||||
|
SetServerUrlParams,
|
||||||
|
LLMStatusChangedEvent,
|
||||||
|
LLMProcessProgressEvent,
|
||||||
PermissionStatus
|
PermissionStatus
|
||||||
} from '@shared/types'
|
} from '@shared/types'
|
||||||
import type { IPCResult } from '@shared/errors'
|
import type { IPCResult } from '@shared/errors'
|
||||||
|
|
@ -142,6 +150,25 @@ const electronAPI = {
|
||||||
on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb)
|
on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ── LLM ────────────────────────────────────────────────
|
||||||
|
llm: {
|
||||||
|
getStatus: () => invoke<LLMStatus>(IPC_CHANNELS.LLM.GET_STATUS),
|
||||||
|
getModels: () => invoke<LLMModel[]>(IPC_CHANNELS.LLM.GET_MODELS),
|
||||||
|
getActiveModel: () => invoke<string | null>(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL),
|
||||||
|
setModel: (params: SetLLMModelParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.LLM.SET_MODEL, params),
|
||||||
|
process: (params: LLMProcessParams) =>
|
||||||
|
invoke<LLMProcessResult>(IPC_CHANNELS.LLM.PROCESS, params),
|
||||||
|
cancelProcess: () => invoke<void>(IPC_CHANNELS.LLM.CANCEL_PROCESS),
|
||||||
|
getServerUrl: () => invoke<string>(IPC_CHANNELS.LLM.GET_SERVER_URL),
|
||||||
|
setServerUrl: (params: SetServerUrlParams) =>
|
||||||
|
invoke<void>(IPC_CHANNELS.LLM.SET_SERVER_URL, params),
|
||||||
|
onStatusChanged: (cb: (e: LLMStatusChangedEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.LLM.STATUS_CHANGED, cb),
|
||||||
|
onProcessProgress: (cb: (e: LLMProcessProgressEvent) => void): Unsubscribe =>
|
||||||
|
on(IPC_CHANNELS.LLM.PROCESS_PROGRESS, cb)
|
||||||
|
},
|
||||||
|
|
||||||
// ── Window ─────────────────────────────────────────────
|
// ── Window ─────────────────────────────────────────────
|
||||||
window: {
|
window: {
|
||||||
minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE),
|
minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE),
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import MenuBookIcon from '@mui/icons-material/MenuBook'
|
||||||
import SettingsIcon from '@mui/icons-material/Settings'
|
import SettingsIcon from '@mui/icons-material/Settings'
|
||||||
import { DashboardPage } from '../pages/DashboardPage'
|
import { DashboardPage } from '../pages/DashboardPage'
|
||||||
import { SettingsModal } from './SettingsModal'
|
import { SettingsModal } from './SettingsModal'
|
||||||
|
import { StatusBar } from './StatusBar'
|
||||||
|
|
||||||
type Route = 'dashboard' | 'history' | 'dictionary'
|
type Route = 'dashboard' | 'history' | 'dictionary'
|
||||||
|
|
||||||
|
|
@ -34,7 +35,8 @@ export function AppLayout(): React.ReactElement {
|
||||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box sx={{ display: 'flex', height: '100vh' }}>
|
<Box sx={{ display: 'flex', height: '100vh', flexDirection: 'column' }}>
|
||||||
|
<Box sx={{ display: 'flex', flex: 1, overflow: 'hidden' }}>
|
||||||
{/* Sidebar Drawer */}
|
{/* Sidebar Drawer */}
|
||||||
<Drawer
|
<Drawer
|
||||||
variant="permanent"
|
variant="permanent"
|
||||||
|
|
@ -107,7 +109,8 @@ export function AppLayout(): React.ReactElement {
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
|
</Box>
|
||||||
|
<StatusBar />
|
||||||
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
<SettingsModal open={settingsOpen} onClose={() => setSettingsOpen(false)} />
|
||||||
</Box>
|
</Box>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
69
src/renderer/components/StatusBar.tsx
Normal file
69
src/renderer/components/StatusBar.tsx
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
// src/renderer/components/StatusBar.tsx
|
||||||
|
// 하단 상태 표시: Ollama 연결 상태
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Box, Chip } from '@mui/material'
|
||||||
|
import CircleIcon from '@mui/icons-material/Circle'
|
||||||
|
import type { LLMStatus } from '@shared/types'
|
||||||
|
|
||||||
|
export function StatusBar(): React.ReactElement {
|
||||||
|
const [llmStatus, setLlmStatus] = useState<LLMStatus | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// 초기 상태 조회
|
||||||
|
window.electronAPI.llm.getStatus().then((result) => {
|
||||||
|
if (result.success) setLlmStatus(result.data)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 상태 변경 구독
|
||||||
|
const unsub = window.electronAPI.llm.onStatusChanged((event) => {
|
||||||
|
setLlmStatus(event.status)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5초마다 폴링 (main에서 이벤트를 보내지 않을 수 있으므로)
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
window.electronAPI.llm.getStatus().then((result) => {
|
||||||
|
if (result.success) setLlmStatus(result.data)
|
||||||
|
})
|
||||||
|
}, 5000)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsub()
|
||||||
|
clearInterval(interval)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const connected = llmStatus?.connectionState === 'connected'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 1,
|
||||||
|
px: 2,
|
||||||
|
py: 0.5,
|
||||||
|
borderTop: 1,
|
||||||
|
borderColor: 'divider',
|
||||||
|
bgcolor: 'background.paper'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Chip
|
||||||
|
icon={<CircleIcon sx={{ fontSize: 8 }} />}
|
||||||
|
label={connected ? 'Ollama Connected' : 'Ollama Offline'}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color={connected ? 'success' : 'default'}
|
||||||
|
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
||||||
|
/>
|
||||||
|
{llmStatus?.activeModel && (
|
||||||
|
<Chip
|
||||||
|
label={llmStatus.activeModel}
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue