From e043b85bcdecdb55fee4268f3a498183045ae942 Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Sun, 13 Jul 2025 18:14:24 +0900 Subject: [PATCH] local llm bridge communication --- src/bridge/featureBridge.js | 185 +++++++++++++++++- src/features/ask/askService.js | 110 ++++++++--- src/features/common/ai/factory.js | 3 +- src/features/common/ai/providers/whisper.js | 7 +- .../common/services/localAIServiceBase.js | 61 +++++- .../common/services/localProgressTracker.js | 138 ------------- .../common/services/modelStateService.js | 132 ++++++++----- src/features/common/services/ollamaService.js | 92 +++++---- .../common/services/whisperService.js | 22 +-- src/features/settings/settingsService.js | 9 +- src/ui/app/ApiKeyHeader.js | 5 +- src/ui/settings/SettingsView.js | 70 ++++--- src/window/windowManager.js | 47 +++++ 13 files changed, 563 insertions(+), 318 deletions(-) delete mode 100644 src/features/common/services/localProgressTracker.js diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index 7df4a83..94bb808 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -1,11 +1,12 @@ // src/bridge/featureBridge.js -const { ipcMain, app } = require('electron'); +const { ipcMain, app, BrowserWindow } = require('electron'); const settingsService = require('../features/settings/settingsService'); const authService = require('../features/common/services/authService'); const whisperService = require('../features/common/services/whisperService'); const ollamaService = require('../features/common/services/ollamaService'); const modelStateService = require('../features/common/services/modelStateService'); const shortcutsService = require('../features/shortcuts/shortcutsService'); +const presetRepository = require('../features/common/repositories/preset'); const askService = require('../features/ask/askService'); const listenService = require('../features/listen/listenService'); @@ -15,6 +16,9 @@ module.exports = { // Renderer로부터의 요청을 수신 initialize() { + // 서비스 이벤트 리스너 설정 + this._setupServiceEventListeners(); + // Settings Service ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets()); ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting()); @@ -51,7 +55,33 @@ module.exports = { ipcMain.handle('quit-application', () => app.quit()); // Whisper - ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(event, modelId)); + ipcMain.handle('whisper:download-model', async (event, modelId) => { + // 개별 진행률 이벤트 처리 + const progressHandler = (data) => { + if (data.modelId === modelId) { + event.sender.send('whisper:download-progress', data); + } + }; + + const completeHandler = (data) => { + if (data.modelId === modelId) { + event.sender.send('whisper:download-complete', data); + whisperService.removeListener('download-progress', progressHandler); + whisperService.removeListener('download-complete', completeHandler); + } + }; + + whisperService.on('download-progress', progressHandler); + whisperService.on('download-complete', completeHandler); + + try { + return await whisperService.handleDownloadModel(modelId); + } catch (error) { + whisperService.removeListener('download-progress', progressHandler); + whisperService.removeListener('download-complete', completeHandler); + throw error; + } + }); ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels()); // General @@ -60,17 +90,91 @@ module.exports = { // Ollama ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus()); - ipcMain.handle('ollama:install', async (event) => await ollamaService.handleInstall(event)); - ipcMain.handle('ollama:start-service', async (event) => await ollamaService.handleStartService(event)); + ipcMain.handle('ollama:install', async (event) => { + // 개별 진행률 이벤트 처리 + const progressHandler = (data) => { + event.sender.send('ollama:install-progress', data); + }; + + const completeHandler = (data) => { + event.sender.send('ollama:install-complete', data); + ollamaService.removeListener('install-progress', progressHandler); + ollamaService.removeListener('install-complete', completeHandler); + }; + + ollamaService.on('install-progress', progressHandler); + ollamaService.on('install-complete', completeHandler); + + try { + return await ollamaService.handleInstall(); + } catch (error) { + ollamaService.removeListener('install-progress', progressHandler); + ollamaService.removeListener('install-complete', completeHandler); + throw error; + } + }); + ipcMain.handle('ollama:start-service', async (event) => { + // 개별 진행률 이벤트 처리 + const completeHandler = (data) => { + event.sender.send('ollama:install-complete', data); + ollamaService.removeListener('install-complete', completeHandler); + }; + + ollamaService.on('install-complete', completeHandler); + + try { + return await ollamaService.handleStartService(); + } catch (error) { + ollamaService.removeListener('install-complete', completeHandler); + throw error; + } + }); ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady()); ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels()); ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions()); - ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(event, modelName)); + ipcMain.handle('ollama:pull-model', async (event, modelName) => { + // 개별 진행률 이벤트 처리 + const progressHandler = (data) => { + if (data.model === modelName) { + event.sender.send('ollama:pull-progress', data); + } + }; + + const completeHandler = (data) => { + if (data.model === modelName) { + event.sender.send('ollama:pull-complete', data); + ollamaService.removeListener('pull-progress', progressHandler); + ollamaService.removeListener('pull-complete', completeHandler); + } + }; + + const errorHandler = (data) => { + if (data.model === modelName) { + event.sender.send('ollama:pull-error', data); + ollamaService.removeListener('pull-progress', progressHandler); + ollamaService.removeListener('pull-complete', completeHandler); + ollamaService.removeListener('pull-error', errorHandler); + } + }; + + ollamaService.on('pull-progress', progressHandler); + ollamaService.on('pull-complete', completeHandler); + ollamaService.on('pull-error', errorHandler); + + try { + return await ollamaService.handlePullModel(modelName); + } catch (error) { + ollamaService.removeListener('pull-progress', progressHandler); + ollamaService.removeListener('pull-complete', completeHandler); + ollamaService.removeListener('pull-error', errorHandler); + throw error; + } + }); ipcMain.handle('ollama:is-model-installed', async (event, modelName) => await ollamaService.handleIsModelInstalled(modelName)); ipcMain.handle('ollama:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName)); ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp()); ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus()); - ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(event, force)); + ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(force)); // Ask ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt)); @@ -118,6 +222,75 @@ module.exports = { console.log('[FeatureBridge] Initialized with all feature handlers.'); }, + // 서비스 이벤트 리스너 설정 + _setupServiceEventListeners() { + // Ollama Service 이벤트 리스너 + ollamaService.on('pull-progress', (data) => { + this._broadcastToAllWindows('ollama:pull-progress', data); + }); + + ollamaService.on('pull-complete', (data) => { + this._broadcastToAllWindows('ollama:pull-complete', data); + }); + + ollamaService.on('pull-error', (data) => { + this._broadcastToAllWindows('ollama:pull-error', data); + }); + + ollamaService.on('download-progress', (data) => { + this._broadcastToAllWindows('ollama:download-progress', data); + }); + + ollamaService.on('download-complete', (data) => { + this._broadcastToAllWindows('ollama:download-complete', data); + }); + + ollamaService.on('download-error', (data) => { + this._broadcastToAllWindows('ollama:download-error', data); + }); + + // Whisper Service 이벤트 리스너 + whisperService.on('download-progress', (data) => { + this._broadcastToAllWindows('whisper:download-progress', data); + }); + + whisperService.on('download-complete', (data) => { + this._broadcastToAllWindows('whisper:download-complete', data); + }); + + whisperService.on('download-error', (data) => { + this._broadcastToAllWindows('whisper:download-error', data); + }); + + // Model State Service 이벤트 리스너 + modelStateService.on('state-changed', (data) => { + this._broadcastToAllWindows('model-state:updated', data); + }); + + modelStateService.on('settings-updated', () => { + this._broadcastToAllWindows('settings-updated'); + }); + + modelStateService.on('force-show-apikey-header', () => { + this._broadcastToAllWindows('force-show-apikey-header'); + }); + + console.log('[FeatureBridge] Service event listeners configured'); + }, + + // 모든 창에 이벤트 방송 + _broadcastToAllWindows(eventName, data = null) { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + if (data !== null) { + win.webContents.send(eventName, data); + } else { + win.webContents.send(eventName); + } + } + }); + }, + // Renderer로 상태를 전송 sendAskProgress(win, progress) { win.webContents.send('feature:ask:progress', progress); diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index 13653b4..43c353b 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -211,7 +211,7 @@ class AskService { let sessionId; try { - console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); + console.log(`[AskService] Processing message: ${userPrompt.substring(0, 50)}...`); this.state = { ...this.state, @@ -237,9 +237,9 @@ class AskService { const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null; const conversationHistory = this._formatConversationForPrompt(conversationHistoryRaw); - const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false); + // 첫 번째 시도: 스크린샷 포함 (가능한 경우) const messages = [ { role: 'system', content: systemPrompt }, { @@ -266,35 +266,78 @@ class AskService { portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined, }); - const response = await streamingLLM.streamChat(messages); - const askWin = getWindowPool()?.get('ask'); + try { + const response = await streamingLLM.streamChat(messages); + const askWin = getWindowPool()?.get('ask'); - if (!askWin || askWin.isDestroyed()) { - console.error("[AskService] Ask window is not available to send stream to."); - response.body.getReader().cancel(); - return { success: false, error: 'Ask window is not available.' }; + if (!askWin || askWin.isDestroyed()) { + console.error("[AskService] Ask window is not available to send stream to."); + response.body.getReader().cancel(); + return { success: false, error: 'Ask window is not available.' }; + } + + const reader = response.body.getReader(); + signal.addEventListener('abort', () => { + console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`); + reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ }); + }); + + await this._processStream(reader, askWin, sessionId, signal); + return { success: true }; + + } catch (multimodalError) { + // 멀티모달 요청이 실패했고 스크린샷이 포함되어 있다면 텍스트만으로 재시도 + if (screenshotBase64 && this._isMultimodalError(multimodalError)) { + console.log(`[AskService] Multimodal request failed, retrying with text-only: ${multimodalError.message}`); + + // 텍스트만으로 메시지 재구성 + const textOnlyMessages = [ + { role: 'system', content: systemPrompt }, + { + role: 'user', + content: `User Request: ${userPrompt.trim()}` + } + ]; + + const fallbackResponse = await streamingLLM.streamChat(textOnlyMessages); + const askWin = getWindowPool()?.get('ask'); + + if (!askWin || askWin.isDestroyed()) { + console.error("[AskService] Ask window is not available for fallback response."); + fallbackResponse.body.getReader().cancel(); + return { success: false, error: 'Ask window is not available.' }; + } + + const fallbackReader = fallbackResponse.body.getReader(); + signal.addEventListener('abort', () => { + console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`); + fallbackReader.cancel(signal.reason).catch(() => {}); + }); + + await this._processStream(fallbackReader, askWin, sessionId, signal); + return { success: true }; + } else { + // 다른 종류의 에러이거나 스크린샷이 없었다면 그대로 throw + throw multimodalError; + } } - const reader = response.body.getReader(); - signal.addEventListener('abort', () => { - console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`); - reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ }); - }); - - await this._processStream(reader, askWin, sessionId, signal); - - return { success: true }; - } catch (error) { - if (error.name === 'AbortError') { - console.log('[AskService] SendMessage operation was successfully aborted.'); - return { success: true, response: 'Cancelled' }; + console.error('[AskService] Error during message processing:', error); + this.state = { + ...this.state, + isLoading: false, + isStreaming: false, + showTextInput: true, + }; + this._broadcastState(); + + const askWin = getWindowPool()?.get('ask'); + if (askWin && !askWin.isDestroyed()) { + const streamError = error.message || 'Unknown error occurred'; + askWin.webContents.send('ask-response-stream-error', { error: streamError }); } - console.error('[AskService] Error processing message:', error); - this.state.isLoading = false; - this.state.error = error.message; - this._broadcastState(); return { success: false, error: error.message }; } } @@ -366,6 +409,23 @@ class AskService { } } + /** + * 멀티모달 관련 에러인지 판단 + * @private + */ + _isMultimodalError(error) { + const errorMessage = error.message?.toLowerCase() || ''; + return ( + errorMessage.includes('vision') || + errorMessage.includes('image') || + errorMessage.includes('multimodal') || + errorMessage.includes('unsupported') || + errorMessage.includes('image_url') || + errorMessage.includes('400') || // Bad Request often for unsupported features + errorMessage.includes('invalid') || + errorMessage.includes('not supported') + ); + } } const askService = new AskService(); diff --git a/src/features/common/ai/factory.js b/src/features/common/ai/factory.js index 6afe2a8..8ccc5ec 100644 --- a/src/features/common/ai/factory.js +++ b/src/features/common/ai/factory.js @@ -68,7 +68,8 @@ const PROVIDERS = { handler: () => { // This needs to remain a function due to its conditional logic for renderer/main process if (typeof window === 'undefined') { - return require("./providers/whisper"); + const { WhisperProvider } = require("./providers/whisper"); + return new WhisperProvider(); } // Return a dummy object for the renderer process return { diff --git a/src/features/common/ai/providers/whisper.js b/src/features/common/ai/providers/whisper.js index 1190977..58cd666 100644 --- a/src/features/common/ai/providers/whisper.js +++ b/src/features/common/ai/providers/whisper.js @@ -184,9 +184,10 @@ class WhisperProvider { async initialize() { if (!this.whisperService) { - const { WhisperService } = require('../../services/whisperService'); - this.whisperService = new WhisperService(); - await this.whisperService.initialize(); + this.whisperService = require('../../services/whisperService'); + if (!this.whisperService.isInitialized) { + await this.whisperService.initialize(); + } } } diff --git a/src/features/common/services/localAIServiceBase.js b/src/features/common/services/localAIServiceBase.js index 45db41b..0431e01 100644 --- a/src/features/common/services/localAIServiceBase.js +++ b/src/features/common/services/localAIServiceBase.js @@ -152,7 +152,8 @@ class LocalAIServiceBase extends EventEmitter { const { onProgress = null, headers = { 'User-Agent': 'Glass-App' }, - timeout = 300000 // 5 minutes default + timeout = 300000, // 5 minutes default + modelId = null // 모델 ID를 위한 추가 옵션 } = options; return new Promise((resolve, reject) => { @@ -190,9 +191,23 @@ class LocalAIServiceBase extends EventEmitter { response.on('data', (chunk) => { downloadedSize += chunk.length; - if (onProgress && totalSize > 0) { + if (totalSize > 0) { const progress = Math.round((downloadedSize / totalSize) * 100); - onProgress(progress, downloadedSize, totalSize); + + // 이벤트 기반 진행률 보고 + if (modelId) { + this.emit('download-progress', { + modelId, + progress, + downloadedSize, + totalSize + }); + } + + // 기존 콜백 지원 (호환성 유지) + if (onProgress) { + onProgress(progress, downloadedSize, totalSize); + } } }); @@ -200,7 +215,7 @@ class LocalAIServiceBase extends EventEmitter { file.on('finish', () => { file.close(() => { - this.emit('download-complete', { url, destination, size: downloadedSize }); + this.emit('download-complete', { url, destination, size: downloadedSize, modelId }); resolve({ success: true, size: downloadedSize }); }); }); @@ -216,7 +231,7 @@ class LocalAIServiceBase extends EventEmitter { request.on('error', (err) => { file.close(); fs.unlink(destination, () => {}); - this.emit('download-error', { url, error: err }); + this.emit('download-error', { url, error: err, modelId }); reject(err); }); @@ -230,11 +245,20 @@ class LocalAIServiceBase extends EventEmitter { } async downloadWithRetry(url, destination, options = {}) { - const { maxRetries = 3, retryDelay = 1000, expectedChecksum = null, ...downloadOptions } = options; + const { + maxRetries = 3, + retryDelay = 1000, + expectedChecksum = null, + modelId = null, // 모델 ID를 위한 추가 옵션 + ...downloadOptions + } = options; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { - const result = await this.downloadFile(url, destination, downloadOptions); + const result = await this.downloadFile(url, destination, { + ...downloadOptions, + modelId + }); if (expectedChecksum) { const isValid = await this.verifyChecksum(destination, expectedChecksum); @@ -248,6 +272,12 @@ class LocalAIServiceBase extends EventEmitter { return result; } catch (error) { if (attempt === maxRetries) { + this.emit('download-error', { + url, + error: error.message, + modelId, + attempt: attempt + }); throw error; } @@ -257,6 +287,23 @@ class LocalAIServiceBase extends EventEmitter { } } + // 모델 pull을 위한 이벤트 발생 메서드 추가 + emitPullProgress(modelId, progress, status = 'pulling') { + this.emit('pull-progress', { + modelId, + progress, + status + }); + } + + emitPullComplete(modelId) { + this.emit('pull-complete', { modelId }); + } + + emitPullError(modelId, error) { + this.emit('pull-error', { modelId, error }); + } + async verifyChecksum(filePath, expectedChecksum) { return new Promise((resolve, reject) => { const hash = crypto.createHash('sha256'); diff --git a/src/features/common/services/localProgressTracker.js b/src/features/common/services/localProgressTracker.js deleted file mode 100644 index 454b431..0000000 --- a/src/features/common/services/localProgressTracker.js +++ /dev/null @@ -1,138 +0,0 @@ -export class LocalProgressTracker { - constructor(serviceName) { - this.serviceName = serviceName; - this.activeOperations = new Map(); // operationId -> { controller, onProgress } - - // Check if we're in renderer process with window.api available - if (!window.api) { - throw new Error(`${serviceName} requires Electron environment with contextBridge`); - } - - this.globalProgressHandler = (event, data) => { - const operation = this.activeOperations.get(data.model || data.modelId); - if (operation && !operation.controller.signal.aborted) { - operation.onProgress(data.progress); - } - }; - - // Set up progress listeners based on service name - if (serviceName.toLowerCase() === 'ollama') { - window.api.settingsView.onOllamaPullProgress(this.globalProgressHandler); - } else if (serviceName.toLowerCase() === 'whisper') { - window.api.settingsView.onWhisperDownloadProgress(this.globalProgressHandler); - } - - this.progressEvent = serviceName.toLowerCase(); - } - - async trackOperation(operationId, operationType, onProgress) { - if (this.activeOperations.has(operationId)) { - throw new Error(`${operationType} ${operationId} is already in progress`); - } - - const controller = new AbortController(); - const operation = { controller, onProgress }; - this.activeOperations.set(operationId, operation); - - try { - let result; - - // Use appropriate API call based on service and operation - if (this.serviceName.toLowerCase() === 'ollama' && operationType === 'install') { - result = await window.api.settingsView.pullOllamaModel(operationId); - } else if (this.serviceName.toLowerCase() === 'whisper' && operationType === 'download') { - result = await window.api.settingsView.downloadWhisperModel(operationId); - } else { - throw new Error(`Unsupported operation: ${this.serviceName}:${operationType}`); - } - - if (!result.success) { - throw new Error(result.error || `${operationType} failed`); - } - - return true; - } catch (error) { - if (!controller.signal.aborted) { - throw error; - } - return false; - } finally { - this.activeOperations.delete(operationId); - } - } - - async installModel(modelName, onProgress) { - return this.trackOperation(modelName, 'install', onProgress); - } - - async downloadModel(modelId, onProgress) { - return this.trackOperation(modelId, 'download', onProgress); - } - - cancelOperation(operationId) { - const operation = this.activeOperations.get(operationId); - if (operation) { - operation.controller.abort(); - this.activeOperations.delete(operationId); - } - } - - cancelAllOperations() { - for (const [operationId, operation] of this.activeOperations) { - operation.controller.abort(); - } - this.activeOperations.clear(); - } - - isOperationActive(operationId) { - return this.activeOperations.has(operationId); - } - - getActiveOperations() { - return Array.from(this.activeOperations.keys()); - } - - destroy() { - this.cancelAllOperations(); - - // Remove progress listeners based on service name - if (this.progressEvent === 'ollama') { - window.api.settingsView.removeOnOllamaPullProgress(this.globalProgressHandler); - } else if (this.progressEvent === 'whisper') { - window.api.settingsView.removeOnWhisperDownloadProgress(this.globalProgressHandler); - } - } -} - -let trackers = new Map(); - -export function getLocalProgressTracker(serviceName) { - if (!trackers.has(serviceName)) { - trackers.set(serviceName, new LocalProgressTracker(serviceName)); - } - return trackers.get(serviceName); -} - -export function destroyLocalProgressTracker(serviceName) { - const tracker = trackers.get(serviceName); - if (tracker) { - tracker.destroy(); - trackers.delete(serviceName); - } -} - -export function destroyAllProgressTrackers() { - for (const [name, tracker] of trackers) { - tracker.destroy(); - } - trackers.clear(); -} - -// Legacy compatibility exports -export function getOllamaProgressTracker() { - return getLocalProgressTracker('ollama'); -} - -export function destroyOllamaProgressTracker() { - destroyLocalProgressTracker('ollama'); -} \ No newline at end of file diff --git a/src/features/common/services/modelStateService.js b/src/features/common/services/modelStateService.js index e60a2e0..668c870 100644 --- a/src/features/common/services/modelStateService.js +++ b/src/features/common/services/modelStateService.js @@ -1,6 +1,6 @@ const Store = require('electron-store'); const fetch = require('node-fetch'); -const { ipcMain, webContents } = require('electron'); +const { EventEmitter } = require('events'); const { PROVIDERS, getProviderClass } = require('../ai/factory'); const encryptionService = require('./encryptionService'); const providerSettingsRepository = require('../repositories/providerSettings'); @@ -9,8 +9,9 @@ const userModelSelectionsRepository = require('../repositories/userModelSelectio // Import authService directly (singleton) const authService = require('./authService'); -class ModelStateService { +class ModelStateService extends EventEmitter { constructor() { + super(); this.authService = authService; this.store = new Store({ name: 'pickle-glass-model-state' }); this.state = {}; @@ -171,6 +172,9 @@ class ModelStateService { console.log(`[ModelStateService] State loaded from database for user: ${userId}`); + // Auto-select available models after loading state + this._autoSelectAvailableModels(); + } catch (error) { console.error('[ModelStateService] Failed to load state from database:', error); // Fall back to default state @@ -331,22 +335,25 @@ class ModelStateService { } async setApiKey(provider, key) { - if (provider in this.state.apiKeys) { - this.state.apiKeys[provider] = key; - - const supportedTypes = []; - if (PROVIDERS[provider]?.llmModels.length > 0 || provider === 'ollama') { - supportedTypes.push('llm'); - } - if (PROVIDERS[provider]?.sttModels.length > 0 || provider === 'whisper') { - supportedTypes.push('stt'); - } - - this._autoSelectAvailableModels(supportedTypes); - await this._saveState(); - return true; + console.log(`[ModelStateService] setApiKey: ${provider}`); + if (!provider) { + throw new Error('Provider is required'); } - return false; + + let finalKey = key; + + // Handle encryption for non-firebase providers + if (provider !== 'firebase' && key && key !== 'local') { + finalKey = await encryptionService.encrypt(key); + } + + this.state.apiKeys[provider] = finalKey; + await this._saveState(); + + this._autoSelectAvailableModels([]); + + this.emit('state-changed', this.state); + this.emit('settings-updated'); } getApiKey(provider) { @@ -358,19 +365,15 @@ class ModelStateService { return displayKeys; } - removeApiKey(provider) { - console.log(`[ModelStateService] Removing API key for provider: ${provider}`); - if (provider in this.state.apiKeys) { - this.state.apiKeys[provider] = null; - const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm); - if (llmProvider === provider) this.state.selectedModels.llm = null; - - const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt); - if (sttProvider === provider) this.state.selectedModels.stt = null; - - this._autoSelectAvailableModels(); + async removeApiKey(provider) { + if (this.state.apiKeys[provider]) { + delete this.state.apiKeys[provider]; this._saveState(); - this._logCurrentSelection(); + + this._autoSelectAvailableModels([]); + + this.emit('state-changed', this.state); + this.emit('settings-updated'); return true; } return false; @@ -456,11 +459,36 @@ class ModelStateService { const available = []; const modelList = type === 'llm' ? 'llmModels' : 'sttModels'; - Object.entries(this.state.apiKeys).forEach(([providerId, key]) => { - if (key && PROVIDERS[providerId]?.[modelList]) { + for (const [providerId, key] of Object.entries(this.state.apiKeys)) { + if (!key) continue; + + // Ollama의 경우 데이터베이스에서 설치된 모델을 가져오기 + if (providerId === 'ollama' && type === 'llm') { + try { + const ollamaModelRepository = require('../repositories/ollamaModel'); + const installedModels = ollamaModelRepository.getInstalledModels(); + const ollamaModels = installedModels.map(model => ({ + id: model.name, + name: model.name + })); + available.push(...ollamaModels); + } catch (error) { + console.warn('[ModelStateService] Failed to get Ollama models from DB:', error.message); + } + } + // Whisper의 경우 정적 모델 목록 사용 (설치 상태는 별도 확인) + else if (providerId === 'whisper' && type === 'stt') { + // Whisper 모델은 factory.js의 정적 목록 사용 + if (PROVIDERS[providerId]?.[modelList]) { + available.push(...PROVIDERS[providerId][modelList]); + } + } + // 다른 provider들은 기존 로직 사용 + else if (PROVIDERS[providerId]?.[modelList]) { available.push(...PROVIDERS[providerId][modelList]); } - }); + } + return [...new Map(available.map(item => [item.id, item])).values()]; } @@ -469,20 +497,28 @@ class ModelStateService { } setSelectedModel(type, modelId) { - const provider = this.getProviderForModel(type, modelId); - if (provider && this.state.apiKeys[provider]) { - const previousModel = this.state.selectedModels[type]; - this.state.selectedModels[type] = modelId; - this._saveState(); - - // Auto warm-up for Ollama LLM models when changed - if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) { - this._autoWarmUpOllamaModel(modelId, previousModel); - } - - return true; + const availableModels = this.getAvailableModels(type); + const isAvailable = availableModels.some(model => model.id === modelId); + + if (!isAvailable) { + console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`); + return false; } - return false; + + const previousModelId = this.state.selectedModels[type]; + this.state.selectedModels[type] = modelId; + this._saveState(); + + console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`); + + // Auto warm-up for Ollama models + if (type === 'llm' && modelId && modelId !== previousModelId) { + this._autoWarmUpOllamaModel(modelId, previousModelId); + } + + this.emit('state-changed', this.state); + this.emit('settings-updated'); + return true; } /** @@ -544,13 +580,11 @@ class ModelStateService { async handleRemoveApiKey(provider) { console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`); - const success = this.removeApiKey(provider); + const success = await this.removeApiKey(provider); if (success) { const selectedModels = this.getSelectedModels(); if (!selectedModels.llm || !selectedModels.stt) { - webContents.getAllWebContents().forEach(wc => { - wc.send('force-show-apikey-header'); - }); + this.emit('force-show-apikey-header'); } } return success; diff --git a/src/features/common/services/ollamaService.js b/src/features/common/services/ollamaService.js index 8506651..d52e259 100644 --- a/src/features/common/services/ollamaService.js +++ b/src/features/common/services/ollamaService.js @@ -28,7 +28,7 @@ class OllamaService extends LocalAIServiceBase { // Configuration this.requestTimeout = 8000; // 8s for health checks - this.warmupTimeout = 15000; // 15s for model warmup + this.warmupTimeout = 60000; // 60s for model warmup (늘림) this.healthCheckInterval = 60000; // 1min between health checks this.circuitBreakerThreshold = 3; this.circuitBreakerCooldown = 30000; // 30s @@ -639,8 +639,48 @@ class OllamaService extends LocalAIServiceBase { return true; } catch (error) { - console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message); - return false; + // Check if it's a 404 error (model not found/installed) + if (error.message.includes('HTTP 404') || error.message.includes('Not Found')) { + console.log(`[OllamaService] Model ${modelName} not found (404), attempting to install...`); + + try { + // Try to install the model + await this.pullModel(modelName); + console.log(`[OllamaService] Successfully installed model ${modelName}, retrying warm-up...`); + + // Update database to reflect installation + await ollamaModelRepository.updateInstallStatus(modelName, true, false); + + // Retry warm-up after installation + const retryResponse = await this._makeRequest(`${this.baseUrl}/api/chat`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: modelName, + messages: [ + { role: 'user', content: 'Hi' } + ], + stream: false, + options: { + num_predict: 1, + temperature: 0 + } + }), + timeout: this.warmupTimeout + }, `warmup_retry_${modelName}`); + + console.log(`[OllamaService] Successfully warmed up model ${modelName} after installation`); + return true; + + } catch (installError) { + console.error(`[OllamaService] Failed to auto-install model ${modelName}:`, installError.message); + await ollamaModelRepository.updateInstallStatus(modelName, false, false); + return false; + } + } else { + console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message); + return false; + } } } @@ -671,14 +711,8 @@ class OllamaService extends LocalAIServiceBase { return false; } - // Check if model is installed - const isInstalled = await this.isModelInstalled(llmModelId); - if (!isInstalled) { - console.log(`[OllamaService] Model ${llmModelId} not installed, skipping warm-up`); - return false; - } - - console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId}`); + // 설치 여부 체크 제거 - _performWarmUp에서 자동으로 설치 처리 + console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId} (will auto-install if needed)`); return await this.warmUpModel(llmModelId); } catch (error) { @@ -844,10 +878,10 @@ class OllamaService extends LocalAIServiceBase { } } - async handleInstall(event) { + async handleInstall() { try { const onProgress = (data) => { - event.sender.send('ollama:install-progress', data); + this.emit('install-progress', data); }; await this.autoInstall(onProgress); @@ -857,26 +891,26 @@ class OllamaService extends LocalAIServiceBase { await this.startService(); onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 }); } - event.sender.send('ollama:install-complete', { success: true }); + this.emit('install-complete', { success: true }); return { success: true }; } catch (error) { console.error('[OllamaService] Failed to install:', error); - event.sender.send('ollama:install-complete', { success: false, error: error.message }); + this.emit('install-complete', { success: false, error: error.message }); return { success: false, error: error.message }; } } - async handleStartService(event) { + async handleStartService() { try { if (!await this.isServiceRunning()) { console.log('[OllamaService] Starting Ollama service...'); await this.startService(); } - event.sender.send('ollama:install-complete', { success: true }); + this.emit('install-complete', { success: true }); return { success: true }; } catch (error) { console.error('[OllamaService] Failed to start service:', error); - event.sender.send('ollama:install-complete', { success: false, error: error.message }); + this.emit('install-complete', { success: false, error: error.message }); return { success: false, error: error.message }; } } @@ -914,29 +948,12 @@ class OllamaService extends LocalAIServiceBase { } } - async handlePullModel(event, modelName) { + async handlePullModel(modelName) { try { console.log(`[OllamaService] Starting model pull: ${modelName}`); await ollamaModelRepository.updateInstallStatus(modelName, false, true); - const progressHandler = (data) => { - if (data.model === modelName) { - event.sender.send('ollama:pull-progress', data); - } - }; - - const completeHandler = (data) => { - if (data.model === modelName) { - console.log(`[OllamaService] Model ${modelName} pull completed`); - this.removeListener('pull-progress', progressHandler); - this.removeListener('pull-complete', completeHandler); - } - }; - - this.on('pull-progress', progressHandler); - this.on('pull-complete', completeHandler); - await this.pullModel(modelName); await ollamaModelRepository.updateInstallStatus(modelName, true, false); @@ -946,6 +963,7 @@ class OllamaService extends LocalAIServiceBase { } catch (error) { console.error('[OllamaService] Failed to pull model:', error); await ollamaModelRepository.updateInstallStatus(modelName, false, false); + this.emit('pull-error', { model: modelName, error: error.message }); return { success: false, error: error.message }; } } @@ -990,7 +1008,7 @@ class OllamaService extends LocalAIServiceBase { } } - async handleShutdown(event, force = false) { + async handleShutdown(force = false) { try { console.log(`[OllamaService] Manual shutdown requested (force: ${force})`); const success = await this.shutdown(force); diff --git a/src/features/common/services/whisperService.js b/src/features/common/services/whisperService.js index 5d14f44..2fdd754 100644 --- a/src/features/common/services/whisperService.js +++ b/src/features/common/services/whisperService.js @@ -157,19 +157,21 @@ class WhisperService extends LocalAIServiceBase { const modelPath = await this.getModelPath(modelId); const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId]; - this.emit('downloadProgress', { modelId, progress: 0 }); + this.emit('download-progress', { modelId, progress: 0 }); await this.downloadWithRetry(modelInfo.url, modelPath, { expectedChecksum: checksumInfo?.sha256, + modelId, // modelId를 전달하여 LocalAIServiceBase에서 이벤트 발생 시 사용 onProgress: (progress) => { - this.emit('downloadProgress', { modelId, progress }); + this.emit('download-progress', { modelId, progress }); } }); console.log(`[WhisperService] Model ${modelId} downloaded successfully`); + this.emit('download-complete', { modelId }); } - async handleDownloadModel(event, modelId) { + async handleDownloadModel(modelId) { try { console.log(`[WhisperService] Handling download for model: ${modelId}`); @@ -177,19 +179,7 @@ class WhisperService extends LocalAIServiceBase { await this.initialize(); } - const progressHandler = (data) => { - if (data.modelId === modelId && event && event.sender) { - event.sender.send('whisper:download-progress', data); - } - }; - - this.on('downloadProgress', progressHandler); - - try { - await this.ensureModelAvailable(modelId); - } finally { - this.removeListener('downloadProgress', progressHandler); - } + await this.ensureModelAvailable(modelId); return { success: true }; } catch (error) { diff --git a/src/features/settings/settingsService.js b/src/features/settings/settingsService.js index 58e0a98..da68a3a 100644 --- a/src/features/settings/settingsService.js +++ b/src/features/settings/settingsService.js @@ -27,13 +27,16 @@ const NOTIFICATION_CONFIG = { // New facade functions for model state management async function getModelSettings() { try { - const [config, storedKeys, availableLlm, availableStt, selectedModels] = await Promise.all([ + const [config, storedKeys, selectedModels] = await Promise.all([ modelStateService.getProviderConfig(), modelStateService.getAllApiKeys(), - modelStateService.getAvailableModels('llm'), - modelStateService.getAvailableModels('stt'), modelStateService.getSelectedModels(), ]); + + // 동기 함수들은 별도로 호출 + const availableLlm = modelStateService.getAvailableModels('llm'); + const availableStt = modelStateService.getAvailableModels('stt'); + return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } }; } catch (error) { console.error('[SettingsService] Error getting model settings:', error); diff --git a/src/ui/app/ApiKeyHeader.js b/src/ui/app/ApiKeyHeader.js index 0bdbe03..4563b4a 100644 --- a/src/ui/app/ApiKeyHeader.js +++ b/src/ui/app/ApiKeyHeader.js @@ -1,5 +1,5 @@ import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js" -import { getOllamaProgressTracker } from "../../features/common/services/localProgressTracker.js" +// import { getOllamaProgressTracker } from "../../features/common/services/localProgressTracker.js" // 제거됨 export class ApiKeyHeader extends LitElement { //////// after_modelStateService //////// @@ -304,7 +304,6 @@ export class ApiKeyHeader extends LitElement { this.ollamaStatus = { installed: false, running: false }; this.installingModel = null; this.installProgress = 0; - this.progressTracker = getOllamaProgressTracker(); this.whisperInstallingModels = {}; // Professional operation management system @@ -1607,7 +1606,7 @@ export class ApiKeyHeader extends LitElement { // Cancel any ongoing installations when component is destroyed if (this.installingModel) { - this.progressTracker.cancelInstallation(this.installingModel); + // this.progressTracker.cancelInstallation(this.installingModel); // 제거됨 } // Cleanup event listeners diff --git a/src/ui/settings/SettingsView.js b/src/ui/settings/SettingsView.js index aba87a2..9e9db67 100644 --- a/src/ui/settings/SettingsView.js +++ b/src/ui/settings/SettingsView.js @@ -1,5 +1,5 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js'; -import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; +// import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; // 제거됨 export class SettingsView extends LitElement { static styles = css` @@ -531,7 +531,6 @@ export class SettingsView extends LitElement { this.ollamaStatus = { installed: false, running: false }; this.ollamaModels = []; this.installingModels = {}; // { modelName: progress } - this.progressTracker = getOllamaProgressTracker(); // Whisper related this.whisperModels = []; this.whisperProgressTracker = null; // Will be initialized when needed @@ -595,12 +594,12 @@ export class SettingsView extends LitElement { if (modelSettings.success) { const { config, storedKeys, availableLlm, availableStt, selectedModels } = modelSettings.data; - this.providerConfig = config; - this.apiKeys = storedKeys; - this.availableLlmModels = availableLlm; - this.availableSttModels = availableStt; - this.selectedLlm = selectedModels.llm; - this.selectedStt = selectedModels.stt; + this.providerConfig = config; + this.apiKeys = storedKeys; + this.availableLlmModels = availableLlm; + this.availableSttModels = availableStt; + this.selectedLlm = selectedModels.llm; + this.selectedStt = selectedModels.stt; } this.presets = presets || []; @@ -775,31 +774,42 @@ export class SettingsView extends LitElement { } async installOllamaModel(modelName) { - // Mark as installing - this.installingModels = { ...this.installingModels, [modelName]: 0 }; - this.requestUpdate(); - try { - // Use the clean progress tracker - no manual event management needed - const success = await this.progressTracker.installModel(modelName, (progress) => { - this.installingModels = { ...this.installingModels, [modelName]: progress }; - this.requestUpdate(); - }); - - if (success) { - // Refresh status after installation - await this.refreshOllamaStatus(); - await this.refreshModelData(); - // Auto-select the model after installation - await this.selectModel('llm', modelName); - } else { - alert(`Installation of ${modelName} was cancelled`); + // Ollama 모델 다운로드 시작 + this.installingModels = { ...this.installingModels, [modelName]: 0 }; + this.requestUpdate(); + + // 진행률 이벤트 리스너 설정 + const progressHandler = (event, data) => { + if (data.modelId === modelName) { + this.installingModels = { ...this.installingModels, [modelName]: data.progress }; + this.requestUpdate(); + } + }; + + // 진행률 이벤트 리스너 등록 + window.api.settingsView.onOllamaPullProgress(progressHandler); + + try { + const result = await window.api.settingsView.pullOllamaModel(modelName); + + if (result.success) { + console.log(`[SettingsView] Model ${modelName} installed successfully`); + delete this.installingModels[modelName]; + this.requestUpdate(); + + // 상태 새로고침 + await this.refreshOllamaStatus(); + await this.refreshModelData(); + } else { + throw new Error(result.error || 'Installation failed'); + } + } finally { + // 진행률 이벤트 리스너 제거 + window.api.settingsView.removeOnOllamaPullProgress(progressHandler); } } catch (error) { console.error(`[SettingsView] Error installing model ${modelName}:`, error); - alert(`Error installing ${modelName}: ${error.message}`); - } finally { - // Automatic cleanup - no manual event listener management delete this.installingModels[modelName]; this.requestUpdate(); } @@ -891,7 +901,7 @@ export class SettingsView extends LitElement { const installingModels = Object.keys(this.installingModels); if (installingModels.length > 0) { installingModels.forEach(modelName => { - this.progressTracker.cancelInstallation(modelName); + window.api.settingsView.cancelOllamaInstallation(modelName); }); } } diff --git a/src/window/windowManager.js b/src/window/windowManager.js index eacf4da..392da7e 100644 --- a/src/window/windowManager.js +++ b/src/window/windowManager.js @@ -7,6 +7,53 @@ const shortcutsService = require('../features/shortcuts/shortcutsService'); const internalBridge = require('../bridge/internalBridge'); const permissionRepository = require('../features/common/repositories/permission'); +// internalBridge 이벤트 리스너 설정 +function setupInternalBridgeListeners() { + // 창 표시/숨기기 요청 + internalBridge.on('show-window', (windowName, options = {}) => { + console.log(`[WindowManager] Received show-window request for: ${windowName}`); + switch (windowName) { + case 'settings': + showSettingsWindow(options.bounds); + break; + case 'ask': + ensureAskWindowVisible(); + break; + default: + console.warn(`[WindowManager] Unknown window name: ${windowName}`); + } + }); + + internalBridge.on('hide-window', (windowName) => { + console.log(`[WindowManager] Received hide-window request for: ${windowName}`); + switch (windowName) { + case 'settings': + hideSettingsWindow(); + break; + case 'ask': + closeAskWindow(); + break; + default: + console.warn(`[WindowManager] Unknown window name: ${windowName}`); + } + }); + + internalBridge.on('toggle-visibility', () => { + console.log(`[WindowManager] Received toggle-visibility request`); + toggleAllWindowsVisibility(); + }); + + internalBridge.on('set-content-protection', (enabled) => { + console.log(`[WindowManager] Received set-content-protection request: ${enabled}`); + setContentProtection(enabled); + }); + + console.log('[WindowManager] Internal bridge listeners configured'); +} + +// 초기화 시 내부 브릿지 리스너 설정 +setupInternalBridgeListeners(); + /* ────────────────[ GLASS BYPASS ]─────────────── */ let liquidGlass; const isLiquidGlassSupported = () => {