diff --git a/CLAUDE.md b/CLAUDE.md index 1f4424a..0b2dc05 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,10 +77,18 @@ npm run typecheck # tsc --noEmit 6. **녹음 UI**: 9개 웨이브 바, cos 분포 가중치, 100ms 애니메이션 ## 현재 상태 -Phase: 3 완료 -마지막 완료: Phase 3 — TextInsertService + RecordingTip/ResultPopup 팝업 + Settings 모달 -다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 (D3RO 고유 기능) -차단 이슈: SoX 미설치 시 AudioCaptureService 동작 불가, @nut-tree/nut-js → @nut-tree-fork/nut-js 포크 사용 +Phase: 4 완료 +마지막 완료: Phase 4 — Ollama LLM 연동 (텍스트 다듬기, 번역, 스트리밍) +다음 작업: Phase 3.5 — 커서 위치 히스토리 팝업 또는 Phase 5 — TTS + DB +차단 이슈: 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 구현 내용 - TextInsertService: clipboard save→set→Ctrl+V→restore (@nut-tree-fork/nut-js, lazy dynamic import) diff --git a/src/main/bootstrap.ts b/src/main/bootstrap.ts index 8d50c79..b9d7828 100644 --- a/src/main/bootstrap.ts +++ b/src/main/bootstrap.ts @@ -5,6 +5,7 @@ import { initLoggerService, getLogger } from './services/LoggerService' import { initConfigService } from './services/ConfigService' import { getHotkeyService } from './services/HotkeyService' import { getVoiceModeService } from './services/VoiceModeService' +import { getLocalLLMService } from './services/LocalLLMService' import { createMainWindow, preloadPopupWindows, @@ -34,7 +35,8 @@ export async function bootstrap(): Promise { { name: 'ipc-handlers', critical: true, fn: initIpcHandlers }, { name: 'popup-preload', critical: false, fn: initPopupWindows }, { 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) { @@ -120,3 +122,8 @@ async function initVoiceMode(): Promise { setTimeout(() => hideRecordingTip(), 3000) }) } + +async function initLLMPolling(): Promise { + const llm = getLocalLLMService() + llm.startPolling() +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 0cc962c..8daeffb 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -7,6 +7,7 @@ import { registerSystemHandlers } from './system-handlers' import { registerVoiceHandlers } from './voice-handlers' import { registerSTTHandlers } from './stt-handlers' import { registerHotkeyHandlers } from './hotkey-handlers' +import { registerLLMHandlers } from './llm-handlers' import { getLogger } from '../services/LoggerService' const logger = getLogger('ipc') @@ -19,5 +20,6 @@ export function registerAllIpcHandlers(): void { registerVoiceHandlers() registerSTTHandlers() registerHotkeyHandlers() + registerLLMHandlers() logger.info('All IPC handlers registered') } diff --git a/src/main/ipc/llm-handlers.ts b/src/main/ipc/llm-handlers.ts new file mode 100644 index 0000000..63883ef --- /dev/null +++ b/src/main/ipc/llm-handlers.ts @@ -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) + }) +} diff --git a/src/main/services/LocalLLMService.ts b/src/main/services/LocalLLMService.ts new file mode 100644 index 0000000..749a51b --- /dev/null +++ b/src/main/services/LocalLLMService.ts @@ -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 = { + refine: `다음 음성 전사 텍스트를 자연스럽고 격식 있는 문어체로 다듬어주세요. +원래 의미를 유지하면서 문법 오류를 수정하고, 불필요한 반복이나 필러를 제거하세요. +다듬어진 텍스트만 출력하세요. 설명이나 부가 문구를 붙이지 마세요.`, + + translate: `다음 텍스트를 {{targetLanguage}}로 번역해주세요. +자연스럽고 정확한 번역만 출력하세요. 원문이나 설명을 붙이지 마세요.`, + + summarize: `다음 텍스트의 핵심 내용을 3줄 이내로 요약해주세요. +요약문만 출력하세요.`, + + grammar: `다음 텍스트의 문법 오류만 수정해주세요. +원래 의미와 톤을 유지하면서 문법 오류만 수정하세요. +수정된 텍스트만 출력하세요.`, + + expand: `다음 텍스트를 더 자세하고 풍부하게 확장해주세요. +확장된 텍스트만 출력하세요.` +} + +// ============================================================ +// LocalLLMService +// ============================================================ + +class LocalLLMService extends EventEmitter { + private _state = LLMState.Unavailable + private _pollInterval: ReturnType | 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 { + 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 + ): AsyncGenerator { + 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 { + 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 { + 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 { + 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(event: K, listener: LocalLLMEvents[K]): this { + return super.on(event, listener) + } + + override off(event: K, listener: LocalLLMEvents[K]): this { + return super.off(event, listener) + } + + override emit( + event: K, + ...args: Parameters + ): boolean { + return super.emit(event, ...args) + } +} + +// ── 싱글톤 ───────────────────────────────────────────── + +let instance: LocalLLMService | null = null + +export function getLocalLLMService(): LocalLLMService { + if (!instance) { + instance = new LocalLLMService() + } + return instance +} diff --git a/src/main/services/VoiceModeService.ts b/src/main/services/VoiceModeService.ts index 5ecc1c2..03a9783 100644 --- a/src/main/services/VoiceModeService.ts +++ b/src/main/services/VoiceModeService.ts @@ -13,6 +13,7 @@ import { getHotkeyService } from './HotkeyService' import type { HotkeyConfig } from './HotkeyService' import { configGet } from './ConfigService' import { getTextInsertService } from './TextInsertService' +import { getLocalLLMService } from './LocalLLMService' import { D3ROError, ErrorCode } from '@shared/errors' import { TIMING } from '@shared/constants' import { RecognitionState, AudioState } from '@shared/types' @@ -407,8 +408,14 @@ class VoiceModeService extends EventEmitter { this.emit('transcription-update', { text: result.text, isFinal: true }) - // Phase 2: LLM 후처리 없이 바로 완료 - this._completeSession(result.text) + // LLM 후처리 + const llmAction = configGet('defaultLLMAction') + if (llmAction !== 'refine' || !getLocalLLMService().isAvailable()) { + // LLM 미가용이거나 기본 액션이면 원본 텍스트로 완료 + this._completeSession(result.text) + } else { + await this._processWithLLM(result.text) + } } catch (error) { if (this._isInTerminalState()) return this._handleError( @@ -420,6 +427,35 @@ class VoiceModeService extends EventEmitter { } } + // ── LLM 후처리 ───────────────────────────────────────── + + private async _processWithLLM(transcribedText: string): Promise { + 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 { diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 3fc70de..32828ef 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -25,3 +25,4 @@ export { getHotkeyService } from './HotkeyService' export { getVoiceModeService } from './VoiceModeService' export { getAudioCaptureService } from './AudioCaptureService' export { getTextInsertService } from './TextInsertService' +export { getLocalLLMService } from './LocalLLMService' diff --git a/src/preload/index.ts b/src/preload/index.ts index b2970e7..3329222 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -39,6 +39,14 @@ import type { SetHotkeyParams, SetEnabledParams, HotkeyTriggeredEvent, + LLMStatus, + LLMModel, + LLMProcessParams, + LLMProcessResult, + SetLLMModelParams, + SetServerUrlParams, + LLMStatusChangedEvent, + LLMProcessProgressEvent, PermissionStatus } from '@shared/types' import type { IPCResult } from '@shared/errors' @@ -142,6 +150,25 @@ const electronAPI = { on(IPC_CHANNELS.HOTKEY.TRIGGERED, cb) }, + // ── LLM ──────────────────────────────────────────────── + llm: { + getStatus: () => invoke(IPC_CHANNELS.LLM.GET_STATUS), + getModels: () => invoke(IPC_CHANNELS.LLM.GET_MODELS), + getActiveModel: () => invoke(IPC_CHANNELS.LLM.GET_ACTIVE_MODEL), + setModel: (params: SetLLMModelParams) => + invoke(IPC_CHANNELS.LLM.SET_MODEL, params), + process: (params: LLMProcessParams) => + invoke(IPC_CHANNELS.LLM.PROCESS, params), + cancelProcess: () => invoke(IPC_CHANNELS.LLM.CANCEL_PROCESS), + getServerUrl: () => invoke(IPC_CHANNELS.LLM.GET_SERVER_URL), + setServerUrl: (params: SetServerUrlParams) => + invoke(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: { minimize: () => send(IPC_CHANNELS.WINDOW.MINIMIZE), diff --git a/src/renderer/components/AppLayout.tsx b/src/renderer/components/AppLayout.tsx index 4be6386..5c113dd 100644 --- a/src/renderer/components/AppLayout.tsx +++ b/src/renderer/components/AppLayout.tsx @@ -18,6 +18,7 @@ import MenuBookIcon from '@mui/icons-material/MenuBook' import SettingsIcon from '@mui/icons-material/Settings' import { DashboardPage } from '../pages/DashboardPage' import { SettingsModal } from './SettingsModal' +import { StatusBar } from './StatusBar' type Route = 'dashboard' | 'history' | 'dictionary' @@ -34,7 +35,8 @@ export function AppLayout(): React.ReactElement { const [settingsOpen, setSettingsOpen] = useState(false) return ( - + + {/* Sidebar Drawer */} )} - + + setSettingsOpen(false)} /> ) diff --git a/src/renderer/components/StatusBar.tsx b/src/renderer/components/StatusBar.tsx new file mode 100644 index 0000000..88911a8 --- /dev/null +++ b/src/renderer/components/StatusBar.tsx @@ -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(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 ( + + } + label={connected ? 'Ollama Connected' : 'Ollama Offline'} + size="small" + variant="outlined" + color={connected ? 'success' : 'default'} + sx={{ height: 22, '& .MuiChip-label': { fontSize: 11 } }} + /> + {llmStatus?.activeModel && ( + + )} + + ) +}