From 4d93df09e2823dea68499c39ffb56f89c795beaa Mon Sep 17 00:00:00 2001 From: sanio Date: Tue, 15 Jul 2025 01:01:17 +0900 Subject: [PATCH 1/6] centralized window layout/movement feature to windowmanager --- src/bridge/featureBridge.js | 9 +- src/bridge/windowBridge.js | 8 +- src/features/ask/askService.js | 34 +- src/features/listen/listenService.js | 5 +- src/features/shortcuts/shortcutsService.js | 121 +++--- src/preload.js | 24 +- src/ui/app/MainHeader.js | 45 +-- src/ui/settings/SettingsView.js | 10 +- src/ui/settings/ShortCutSettingsView.js | 2 +- src/window/windowLayoutManager.js | 85 ++-- src/window/windowManager.js | 438 ++++++++++----------- 11 files changed, 379 insertions(+), 402 deletions(-) diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index 4dde899..be6d348 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -29,9 +29,12 @@ module.exports = { ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama()); // Shortcuts - ipcMain.handle('get-current-shortcuts', async () => await shortcutsService.loadKeybinds()); - ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults()); - ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds)); + ipcMain.handle('settings:getCurrentShortcuts', async () => await shortcutsService.loadKeybinds()); + ipcMain.handle('shortcut:getDefaultShortcuts', async () => await shortcutsService.handleRestoreDefaults()); + ipcMain.handle('shortcut:closeShortcutSettingsWindow', async () => await shortcutsService.closeShortcutSettingsWindow()); + ipcMain.handle('shortcut:openShortcutSettingsWindow', async () => await shortcutsService.openShortcutSettingsWindow()); + ipcMain.handle('shortcut:saveShortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds)); + ipcMain.handle('shortcut:toggleAllWindowsVisibility', async () => await shortcutsService.toggleAllWindowsVisibility()); // Permissions ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); diff --git a/src/bridge/windowBridge.js b/src/bridge/windowBridge.js index 6382158..313273c 100644 --- a/src/bridge/windowBridge.js +++ b/src/bridge/windowBridge.js @@ -7,14 +7,13 @@ module.exports = { ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection()); ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args)); ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus()); - ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor()); - ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds)); + ipcMain.on('show-settings-window', () => windowManager.showSettingsWindow()); ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow()); ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow()); + ipcMain.handle('open-login-page', () => windowManager.openLoginPage()); ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage()); ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction)); - ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings')); ipcMain.handle('open-external', (event, url) => shell.openExternal(url)); // Newly moved handlers from windowManager @@ -24,9 +23,6 @@ module.exports = { ipcMain.handle('move-header', (event, newX, newY) => windowManager.moveHeader(newX, newY)); ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY)); ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight)); - ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility()); - // ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender)); - // ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow()); }, notifyFocusChange(win, isFocused) { diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index 78ab69f..4274583 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -3,7 +3,6 @@ const { createStreamingLLM } = require('../common/ai/factory'); // Lazy require helper to avoid circular dependency issues const getWindowManager = () => require('../../window/windowManager'); const internalBridge = require('../../bridge/internalBridge'); -const { EVENTS } = internalBridge; const getWindowPool = () => { try { @@ -162,11 +161,11 @@ class AskService { this._broadcastState(); } else { if (askWindow && askWindow.isVisible()) { - internalBridge.emit('request-window-visibility', { name: 'ask', visible: false }); + internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false }); this.state.isVisible = false; } else { console.log('[AskService] Showing hidden Ask window'); - internalBridge.emit('request-window-visibility', { name: 'ask', visible: true }); + internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true }); this.state.isVisible = true; } if (this.state.isVisible) { @@ -192,7 +191,7 @@ class AskService { }; this._broadcastState(); - internalBridge.emit('request-window-visibility', { name: 'ask', visible: false }); + internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false }); return { success: true }; } @@ -217,7 +216,16 @@ class AskService { * @returns {Promise<{success: boolean, response?: string, error?: string}>} */ async sendMessage(userPrompt, conversationHistoryRaw=[]) { - // ensureAskWindowVisible(); + internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true }); + this.state = { + ...this.state, + isLoading: true, + isStreaming: false, + currentQuestion: userPrompt, + currentResponse: '', + showTextInput: false, + }; + this._broadcastState(); if (this.abortController) { this.abortController.abort('New request received.'); @@ -226,26 +234,10 @@ class AskService { const { signal } = this.abortController; - // if (!userPrompt || userPrompt.trim().length === 0) { - // console.warn('[AskService] Cannot process empty message'); - // return { success: false, error: 'Empty message' }; - // } - - let sessionId; try { console.log(`[AskService] πŸ€– Processing message: ${userPrompt.substring(0, 50)}...`); - - this.state = { - ...this.state, - isLoading: true, - isStreaming: false, - currentQuestion: userPrompt, - currentResponse: '', - showTextInput: false, - }; - this._broadcastState(); sessionId = await sessionRepository.getOrCreateActive('ask'); await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); diff --git a/src/features/listen/listenService.js b/src/features/listen/listenService.js index 924e9b7..d879d60 100644 --- a/src/features/listen/listenService.js +++ b/src/features/listen/listenService.js @@ -5,7 +5,6 @@ const authService = require('../common/services/authService'); const sessionRepository = require('../common/repositories/session'); const sttRepository = require('./stt/repositories'); const internalBridge = require('../../bridge/internalBridge'); -const { EVENTS } = internalBridge; class ListenService { constructor() { @@ -109,7 +108,7 @@ class ListenService { switch (listenButtonText) { case 'Listen': console.log('[ListenService] changeSession to "Listen"'); - internalBridge.emit('request-window-visibility', { name: 'listen', visible: true }); + internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true }); await this.initializeSession(); listenWindow.webContents.send('session-state-changed', { isActive: true }); break; @@ -122,7 +121,7 @@ class ListenService { case 'Done': console.log('[ListenService] changeSession to "Done"'); - internalBridge.emit('request-window-visibility', { name: 'listen', visible: false }); + internalBridge.emit('window:requestVisibility', { name: 'listen', visible: false }); listenWindow.webContents.send('session-state-changed', { isActive: false }); break; diff --git a/src/features/shortcuts/shortcutsService.js b/src/features/shortcuts/shortcutsService.js index 78e5af2..44ad6e7 100644 --- a/src/features/shortcuts/shortcutsService.js +++ b/src/features/shortcuts/shortcutsService.js @@ -10,6 +10,7 @@ class ShortcutsService { this.mouseEventsIgnored = false; this.movementManager = null; this.windowPool = null; + this.allWindowVisibility = true; } initialize(movementManager, windowPool) { @@ -22,6 +23,41 @@ class ShortcutsService { console.log('[ShortcutsService] Initialized with dependencies and event listener.'); } + async openShortcutSettingsWindow () { + const keybinds = await this.loadKeybinds(); + const shortcutWin = this.windowPool.get('shortcut-settings'); + shortcutWin.webContents.send('shortcut:loadShortcuts', keybinds); + + globalShortcut.unregisterAll(); + internalBridge.emit('window:requestVisibility', { name: 'shortcut-settings', visible: true }); + console.log('[ShortcutsService] Shortcut settings window opened.'); + return { success: true }; + } + + async closeShortcutSettingsWindow () { + await this.registerShortcuts(); + internalBridge.emit('window:requestVisibility', { name: 'shortcut-settings', visible: false }); + console.log('[ShortcutsService] Shortcut settings window closed.'); + return { success: true }; + } + + async handleSaveShortcuts(newKeybinds) { + try { + await this.saveKeybinds(newKeybinds); + await this.closeShortcutSettingsWindow(); + return { success: true }; + } catch (error) { + console.error("Failed to save shortcuts:", error); + await this.closeShortcutSettingsWindow(); + return { success: false, error: error.message }; + } + } + + async handleRestoreDefaults() { + const defaults = this.getDefaultKeybinds(); + return defaults; + } + getDefaultKeybinds() { const isMac = process.platform === 'darwin'; return { @@ -72,32 +108,6 @@ class ShortcutsService { return keybinds; } - async handleSaveShortcuts(newKeybinds) { - try { - await this.saveKeybinds(newKeybinds); - const shortcutEditor = this.windowPool.get('shortcut-settings'); - if (shortcutEditor && !shortcutEditor.isDestroyed()) { - shortcutEditor.close(); // This will trigger re-registration on 'closed' event in windowManager - } else { - // If editor wasn't open, re-register immediately - await this.registerShortcuts(); - } - return { success: true }; - } catch (error) { - console.error("Failed to save shortcuts:", error); - // On failure, re-register old shortcuts to be safe - await this.registerShortcuts(); - return { success: false, error: error.message }; - } - } - - async handleRestoreDefaults() { - const defaults = this.getDefaultKeybinds(); - await this.saveKeybinds(defaults); - await this.registerShortcuts(); - return defaults; - } - async saveKeybinds(newKeybinds) { const keybindsToSave = []; for (const action in newKeybinds) { @@ -112,38 +122,22 @@ class ShortcutsService { console.log(`[Shortcuts] Saved keybinds.`); } - toggleAllWindowsVisibility(windowPool) { - const header = windowPool.get('header'); - if (!header) return; - - if (header.isVisible()) { - this.lastVisibleWindows.clear(); - - windowPool.forEach((win, name) => { - if (win && !win.isDestroyed() && win.isVisible()) { - this.lastVisibleWindows.add(name); - } - }); - - this.lastVisibleWindows.forEach(name => { - if (name === 'header') return; - const win = windowPool.get(name); - if (win && !win.isDestroyed()) win.hide(); - }); - header.hide(); - - return; - } - - this.lastVisibleWindows.forEach(name => { - const win = windowPool.get(name); - if (win && !win.isDestroyed()) { - win.show(); - } + async toggleAllWindowsVisibility() { + const targetVisibility = !this.allWindowVisibility; + internalBridge.emit('window:requestToggleAllWindowsVisibility', { + targetVisibility: targetVisibility }); + + if (this.allWindowVisibility) { + await this.registerShortcuts(true); + } else { + await this.registerShortcuts(); + } + + this.allWindowVisibility = !this.allWindowVisibility; } - async registerShortcuts() { + async registerShortcuts(registerOnlyToggleVisibility = false) { if (!this.movementManager || !this.windowPool) { console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.'); return; @@ -168,6 +162,14 @@ class ShortcutsService { sendToRenderer('shortcuts-updated', keybinds); + if (registerOnlyToggleVisibility) { + if (keybinds.toggleVisibility) { + globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility()); + } + console.log('[Shortcuts] registerOnlyToggleVisibility, only toggleVisibility shortcut is registered.'); + return; + } + // --- Hardcoded shortcuts --- const isMac = process.platform === 'darwin'; const modifier = isMac ? 'Cmd' : 'Ctrl'; @@ -195,7 +197,7 @@ class ShortcutsService { // --- User-configurable shortcuts --- if (header?.currentHeaderState === 'apikey') { if (keybinds.toggleVisibility) { - globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility(this.windowPool)); + globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility()); } console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.'); return; @@ -208,7 +210,7 @@ class ShortcutsService { let callback; switch(action) { case 'toggleVisibility': - callback = () => this.toggleAllWindowsVisibility(this.windowPool); + callback = () => this.toggleAllWindowsVisibility(); break; case 'nextStep': callback = () => askService.toggleAskButton(true); @@ -282,4 +284,7 @@ class ShortcutsService { } } -module.exports = new ShortcutsService(); \ No newline at end of file + +const shortcutsService = new ShortcutsService(); + +module.exports = shortcutsService; \ No newline at end of file diff --git a/src/preload.js b/src/preload.js index 6121c84..04b1046 100644 --- a/src/preload.js +++ b/src/preload.js @@ -95,11 +95,14 @@ contextBridge.exposeInMainWorld('api', { // Settings Window Management cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'), - showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds), + showSettingsWindow: () => ipcRenderer.send('show-settings-window'), hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'), // Generic invoke (for dynamic channel names) - invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + // invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), + sendListenButtonClick: (listenButtonText) => ipcRenderer.invoke('listen:changeSession', listenButtonText), + sendAskButtonClick: () => ipcRenderer.invoke('ask:toggleAskButton'), + sendToggleAllWindowsVisibility: () => ipcRenderer.invoke('shortcut:toggleAllWindowsVisibility'), // Listeners onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback), @@ -213,8 +216,8 @@ contextBridge.exposeInMainWorld('api', { setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled), getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'), toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'), - getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'), - openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'), + getCurrentShortcuts: () => ipcRenderer.invoke('settings:getCurrentShortcuts'), + openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'), // Window Management moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction), @@ -245,20 +248,17 @@ contextBridge.exposeInMainWorld('api', { // src/ui/settings/ShortCutSettingsView.js shortcutSettingsView: { // Shortcut Management - saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts), - getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'), - closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'), + saveShortcuts: (shortcuts) => ipcRenderer.invoke('shortcut:saveShortcuts', shortcuts), + getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'), + closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'), // Listeners - onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback), - removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback) + onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback), + removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback) }, // src/ui/app/content.html inline scripts content: { - // Animation Management - // sendAnimationFinished: () => ipcRenderer.send('animation-finished'), - // Listeners onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback), removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback), diff --git a/src/ui/app/MainHeader.js b/src/ui/app/MainHeader.js index 1ace9ea..175c71d 100644 --- a/src/ui/app/MainHeader.js +++ b/src/ui/app/MainHeader.js @@ -2,7 +2,6 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js'; export class MainHeader extends LitElement { static properties = { - // isSessionActive: { type: Boolean, state: true }, isTogglingSession: { type: Boolean, state: true }, shortcuts: { type: Object, state: true }, listenSessionStatus: { type: String, state: true }, @@ -515,30 +514,12 @@ export class MainHeader extends LitElement { } } - invoke(channel, ...args) { - if (this.wasJustDragged) return; - if (window.api) { - window.api.mainHeader.invoke(channel, ...args); - } - // return Promise.resolve(); - } - showSettingsWindow(element) { if (this.wasJustDragged) return; if (window.api) { console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`); - - window.api.mainHeader.cancelHideSettingsWindow(); + window.api.mainHeader.showSettingsWindow(); - if (element) { - const { left, top, width, height } = element.getBoundingClientRect(); - window.api.mainHeader.showSettingsWindow({ - x: left, - y: top, - width, - height, - }); - } } } @@ -559,9 +540,10 @@ export class MainHeader extends LitElement { this.isTogglingSession = true; try { - const channel = 'listen:changeSession'; const listenButtonText = this._getListenButtonText(this.listenSessionStatus); - await this.invoke(channel, listenButtonText); + if (window.api) { + await window.api.mainHeader.sendListenButtonClick(listenButtonText); + } } catch (error) { console.error('IPC invoke for session change failed:', error); this.isTogglingSession = false; @@ -572,13 +554,26 @@ export class MainHeader extends LitElement { if (this.wasJustDragged) return; try { - const channel = 'ask:toggleAskButton'; - await this.invoke(channel); + if (window.api) { + await window.api.mainHeader.sendAskButtonClick(); + } } catch (error) { console.error('IPC invoke for ask button failed:', error); } } + async _handleToggleAllWindowsVisibility() { + if (this.wasJustDragged) return; + + try { + if (window.api) { + await window.api.mainHeader.sendToggleAllWindowsVisibility(); + } + } catch (error) { + console.error('IPC invoke for all windows visibility button failed:', error); + } + } + renderShortcut(accelerator) { if (!accelerator) return html``; @@ -656,7 +651,7 @@ export class MainHeader extends LitElement { -
this.invoke('toggle-all-windows-visibility')}> +
this._handleToggleAllWindowsVisibility()}>
Show/Hide
diff --git a/src/ui/settings/SettingsView.js b/src/ui/settings/SettingsView.js index 8c74a4e..5d3bacb 100644 --- a/src/ui/settings/SettingsView.js +++ b/src/ui/settings/SettingsView.js @@ -879,7 +879,7 @@ export class SettingsView extends LitElement { //////// after_modelStateService //////// openShortcutEditor() { - window.api.settingsView.openShortcutEditor(); + window.api.settingsView.openShortcutSettingsWindow(); } connectedCallback() { @@ -1019,13 +1019,7 @@ export class SettingsView extends LitElement { window.api.settingsView.hideSettingsWindow(); } - // getMainShortcuts() { - // return [ - // { name: 'Show / Hide', key: '\\' }, - // { name: 'Ask Anything', key: '↡' }, - // { name: 'Scroll AI Response', key: '↕' } - // ]; - // } + getMainShortcuts() { return [ { name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility }, diff --git a/src/ui/settings/ShortCutSettingsView.js b/src/ui/settings/ShortCutSettingsView.js index ab5e345..691a059 100644 --- a/src/ui/settings/ShortCutSettingsView.js +++ b/src/ui/settings/ShortCutSettingsView.js @@ -179,7 +179,7 @@ export class ShortcutSettingsView extends LitElement { handleClose() { if (!window.api) return; - window.api.shortcutSettingsView.closeShortcutEditor(); + window.api.shortcutSettingsView.closeShortcutSettingsWindow(); } async handleResetToDefault() { diff --git a/src/window/windowLayoutManager.js b/src/window/windowLayoutManager.js index 56559e6..cdd0ef3 100644 --- a/src/window/windowLayoutManager.js +++ b/src/window/windowLayoutManager.js @@ -142,7 +142,14 @@ class WindowLayoutManager { const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY); this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY); - this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY); + const settings = this.windowPool.get('settings'); + if (settings && !settings.isDestroyed() && settings.isVisible()) { + const settingPos = this.calculateSettingsWindowPosition(); + if (settingPos) { + const { width, height } = settings.getBounds(); + settings.setBounds({ x: settingPos.x, y: settingPos.y, width, height }); + } + } } /** @@ -234,58 +241,54 @@ class WindowLayoutManager { } } - positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) { + /** + * @returns {{x: number, y: number} | null} + */ + calculateSettingsWindowPosition() { + const header = this.windowPool.get('header'); const settings = this.windowPool.get('settings'); - if (!settings?.getBounds || !settings.isVisible()) return; - if (settings.__lockedByButton) { - const headerDisplay = getCurrentDisplay(this.windowPool.get('header')); - const settingsDisplay = getCurrentDisplay(settings); - if (headerDisplay.id !== settingsDisplay.id) { - settings.__lockedByButton = false; - } else { - return; - } + if (!header || header.isDestroyed() || !settings || settings.isDestroyed()) { + return null; } + const headerBounds = header.getBounds(); const settingsBounds = settings.getBounds(); + const display = getCurrentDisplay(header); + const { x: workAreaX, y: workAreaY, width: screenWidth, height: screenHeight } = display.workArea; + const PAD = 5; - const buttonPadding = 17; - let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding; - let y = headerBounds.y + headerBounds.height + PAD; + const buttonPadding = 170; - const otherVisibleWindows = []; - ['listen', 'ask'].forEach(name => { - const win = this.windowPool.get(name); - if (win && win.isVisible() && !win.isDestroyed()) { - otherVisibleWindows.push({ name, bounds: win.getBounds() }); - } - }); + const x = headerBounds.x + headerBounds.width - settingsBounds.width + buttonPadding; + const y = headerBounds.y + headerBounds.height + PAD; - const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height }; - let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds)); + const clampedX = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x)); + const clampedY = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y)); - if (hasOverlap) { - x = headerBounds.x + headerBounds.width + PAD; - y = headerBounds.y; - if (x + settingsBounds.width > screenWidth - 10) { - x = headerBounds.x - settingsBounds.width - PAD; - } - if (x < 10) { - x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding; - y = headerBounds.y - settingsBounds.height - PAD; - if (y < 10) { - x = headerBounds.x + headerBounds.width - settingsBounds.width; - y = headerBounds.y + headerBounds.height + PAD; - } - } + return { x: Math.round(clampedX), y: Math.round(clampedY) }; + } + + positionShortcutSettingsWindow() { + const header = this.windowPool.get('header'); + const shortcutSettings = this.windowPool.get('shortcut-settings'); + + if (!header || header.isDestroyed() || !shortcutSettings || shortcutSettings.isDestroyed()) { + return; } - x = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x)); - y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y)); + const headerBounds = header.getBounds(); + const shortcutBounds = shortcutSettings.getBounds(); + const display = getCurrentDisplay(header); + const { workArea } = display; - settings.setBounds({ x: Math.round(x), y: Math.round(y) }); - settings.moveTop(); + let newX = Math.round(headerBounds.x + (headerBounds.width / 2) - (shortcutBounds.width / 2)); + let newY = Math.round(headerBounds.y); + + newX = Math.max(workArea.x, Math.min(newX, workArea.x + workArea.width - shortcutBounds.width)); + newY = Math.max(workArea.y, Math.min(newY, workArea.y + workArea.height - shortcutBounds.height)); + + shortcutSettings.setBounds({ x: newX, y: newY, width: shortcutBounds.width, height: shortcutBounds.height }); } /** diff --git a/src/window/windowManager.js b/src/window/windowManager.js index 3aa64d9..692199e 100644 --- a/src/window/windowManager.js +++ b/src/window/windowManager.js @@ -5,7 +5,6 @@ const path = require('node:path'); const os = require('os'); const shortcutsService = require('../features/shortcuts/shortcutsService'); const internalBridge = require('../bridge/internalBridge'); -const { EVENTS } = internalBridge; const permissionRepository = require('../features/common/repositories/permission'); /* ────────────────[ GLASS BYPASS ]─────────────── */ @@ -30,9 +29,6 @@ if (shouldUseLiquidGlass) { /* ────────────────[ GLASS BYPASS ]─────────────── */ let isContentProtectionOn = true; -let currentDisplayId = null; - -let mouseEventsIgnored = false; let lastVisibleWindows = new Set(['header']); const HEADER_HEIGHT = 47; const DEFAULT_WINDOW_WIDTH = 353; @@ -42,9 +38,7 @@ const windowPool = new Map(); let settingsHideTimer = null; -let selectedCaptureSourceId = null; -// let shortcutEditorWindow = null; let layoutManager = null; function updateLayout() { if (layoutManager) { @@ -92,20 +86,69 @@ function fadeWindow(win, from, to, duration = FADE_DURATION, onComplete) { }, 1000 / FADE_FPS); } +const showSettingsWindow = () => { + internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true }); +}; -function setupAnimationController(windowPool, layoutManager, movementManager) { - internalBridge.on('request-window-visibility', ({ name, visible }) => { +const hideSettingsWindow = () => { + internalBridge.emit('window:requestVisibility', { name: 'settings', visible: false }); +}; + +const cancelHideSettingsWindow = () => { + internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true }); +}; + + +function setupWindowController(windowPool, layoutManager, movementManager) { + internalBridge.on('window:requestVisibility', ({ name, visible }) => { handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, visible); }); + internalBridge.on('window:requestToggleAllWindowsVisibility', ({ targetVisibility }) => { + changeAllWindowsVisibility(windowPool, targetVisibility); + }); } +function changeAllWindowsVisibility(windowPool, targetVisibility) { + const header = windowPool.get('header'); + if (!header) return; + + if (typeof targetVisibility === 'boolean' && + header.isVisible() === targetVisibility) { + return; + } + + if (header.isVisible()) { + lastVisibleWindows.clear(); + + windowPool.forEach((win, name) => { + if (win && !win.isDestroyed() && win.isVisible()) { + lastVisibleWindows.add(name); + } + }); + + lastVisibleWindows.forEach(name => { + if (name === 'header') return; + const win = windowPool.get(name); + if (win && !win.isDestroyed()) win.hide(); + }); + header.hide(); + + return; + } + + lastVisibleWindows.forEach(name => { + const win = windowPool.get(name); + if (win && !win.isDestroyed()) + win.show(); + }); + } /** * * @param {Map} windowPool * @param {WindowLayoutManager} layoutManager * @param {SmoothMovementManager} movementManager - * @param {'listen' | 'ask'} name + * @param {'listen' | 'ask' | 'settings' | 'shortcut-settings'} name * @param {boolean} shouldBeVisible */ async function handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, shouldBeVisible) { @@ -117,94 +160,171 @@ async function handleWindowVisibilityRequest(windowPool, layoutManager, movement return; } - const isCurrentlyVisible = win.isVisible(); - if (isCurrentlyVisible === shouldBeVisible) { - console.log(`[WindowManager] Window '${name}' is already in the desired state.`); + if (name !== 'settings') { + const isCurrentlyVisible = win.isVisible(); + if (isCurrentlyVisible === shouldBeVisible) { + console.log(`[WindowManager] Window '${name}' is already in the desired state.`); + return; + } + } + + const disableClicks = (selectedWindow) => { + for (const [name, win] of windowPool) { + if (win !== selectedWindow && !win.isDestroyed()) { + win.setIgnoreMouseEvents(true, { forward: true }); + } + } + }; + + const restoreClicks = () => { + for (const [, win] of windowPool) { + if (!win.isDestroyed()) win.setIgnoreMouseEvents(false); + } + }; + + if (name === 'settings') { + if (shouldBeVisible) { + // Cancel any pending hide operations + if (settingsHideTimer) { + clearTimeout(settingsHideTimer); + settingsHideTimer = null; + } + const position = layoutManager.calculateSettingsWindowPosition(); + if (position) { + win.setBounds(position); + win.__lockedByButton = true; + win.show(); + win.moveTop(); + win.setAlwaysOnTop(true); + } else { + console.warn('[WindowManager] Could not calculate settings window position.'); + } + } else { + // Hide after a delay + if (settingsHideTimer) { + clearTimeout(settingsHideTimer); + } + settingsHideTimer = setTimeout(() => { + if (win && !win.isDestroyed()) { + win.setAlwaysOnTop(false); + win.hide(); + } + settingsHideTimer = null; + }, 200); + + win.__lockedByButton = false; + } return; } - const otherName = name === 'listen' ? 'ask' : 'listen'; - const otherWin = windowPool.get(otherName); - const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible(); - - const ANIM_OFFSET_X = 100; - const ANIM_OFFSET_Y = 20; - - if (shouldBeVisible) { - win.setOpacity(0); - - if (name === 'listen') { - if (!isOtherWinVisible) { - const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); - if (!targets.listen) return; - - const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y }; - win.setBounds(startPos); - win.show(); - fadeWindow(win, 0, 1); - movementManager.animateWindow(win, targets.listen.x, targets.listen.y); + if (name === 'shortcut-settings') { + if (shouldBeVisible) { + layoutManager.positionShortcutSettingsWindow(); + if (process.platform === 'darwin') { + win.setAlwaysOnTop(true, 'screen-saver'); } else { - const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); - if (!targets.listen || !targets.ask) return; - - const startListenPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y }; - win.setBounds(startListenPos); - - win.show(); - fadeWindow(win, 0, 1); - movementManager.animateWindow(otherWin, targets.ask.x, targets.ask.y); - movementManager.animateWindow(win, targets.listen.x, targets.listen.y); + win.setAlwaysOnTop(true); } - } else if (name === 'ask') { - if (!isOtherWinVisible) { - const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: false, ask: true }); - if (!targets.ask) return; - - const startPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; - win.setBounds(startPos); - win.show(); - fadeWindow(win, 0, 1); - movementManager.animateWindow(win, targets.ask.x, targets.ask.y); - + // globalShortcut.unregisterAll(); + disableClicks(win); + win.show(); + } else { + if (process.platform === 'darwin') { + win.setAlwaysOnTop(false, 'screen-saver'); } else { - const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); - if (!targets.listen || !targets.ask) return; - - const startAskPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; - win.setBounds(startAskPos); - - win.show(); - fadeWindow(win, 0, 1); - movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y); - movementManager.animateWindow(win, targets.ask.x, targets.ask.y); + win.setAlwaysOnTop(false); } + restoreClicks(); + win.hide(); } - } else { - const currentBounds = win.getBounds(); - fadeWindow( - win, 1, 0, FADE_DURATION, - () => win.hide() - ); - if (name === 'listen') { - if (!isOtherWinVisible) { - const targetX = currentBounds.x - ANIM_OFFSET_X; - movementManager.animateWindow(win, targetX, currentBounds.y); - } else { - const targetX = currentBounds.x - currentBounds.width; - movementManager.animateWindow(win, targetX, currentBounds.y); - } - } else if (name === 'ask') { - if (!isOtherWinVisible) { - const targetY = currentBounds.y - ANIM_OFFSET_Y; - movementManager.animateWindow(win, currentBounds.x, targetY); - } else { - const targetAskY = currentBounds.y - ANIM_OFFSET_Y; - movementManager.animateWindow(win, currentBounds.x, targetAskY); + return; + } - const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); - if (targets.listen) { + if (name === 'listen' || name === 'ask') { + const otherName = name === 'listen' ? 'ask' : 'listen'; + const otherWin = windowPool.get(otherName); + const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible(); + + const ANIM_OFFSET_X = 100; + const ANIM_OFFSET_Y = 20; + + if (shouldBeVisible) { + win.setOpacity(0); + + if (name === 'listen') { + if (!isOtherWinVisible) { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); + if (!targets.listen) return; + + const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y }; + win.setBounds(startPos); + win.show(); + fadeWindow(win, 0, 1); + movementManager.animateWindow(win, targets.listen.x, targets.listen.y); + + } else { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); + if (!targets.listen || !targets.ask) return; + + const startListenPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y }; + win.setBounds(startListenPos); + + win.show(); + fadeWindow(win, 0, 1); + movementManager.animateWindow(otherWin, targets.ask.x, targets.ask.y); + movementManager.animateWindow(win, targets.listen.x, targets.listen.y); + } + } else if (name === 'ask') { + if (!isOtherWinVisible) { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: false, ask: true }); + if (!targets.ask) return; + + const startPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; + win.setBounds(startPos); + win.show(); + fadeWindow(win, 0, 1); + movementManager.animateWindow(win, targets.ask.x, targets.ask.y); + + } else { + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); + if (!targets.listen || !targets.ask) return; + + const startAskPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; + win.setBounds(startAskPos); + + win.show(); + fadeWindow(win, 0, 1); movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y); + movementManager.animateWindow(win, targets.ask.x, targets.ask.y); + } + } + } else { + const currentBounds = win.getBounds(); + fadeWindow( + win, 1, 0, FADE_DURATION, + () => win.hide() + ); + if (name === 'listen') { + if (!isOtherWinVisible) { + const targetX = currentBounds.x - ANIM_OFFSET_X; + movementManager.animateWindow(win, targetX, currentBounds.y); + } else { + const targetX = currentBounds.x - currentBounds.width; + movementManager.animateWindow(win, targetX, currentBounds.y); + } + } else if (name === 'ask') { + if (!isOtherWinVisible) { + const targetY = currentBounds.y - ANIM_OFFSET_Y; + movementManager.animateWindow(win, currentBounds.x, targetY); + } else { + const targetAskY = currentBounds.y - ANIM_OFFSET_Y; + movementManager.animateWindow(win, currentBounds.x, targetAskY); + + const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); + if (targets.listen) { + movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y); + } } } } @@ -276,62 +396,6 @@ const resizeHeaderWindow = ({ width, height }) => { return { success: false, error: 'Header window not found' }; }; -const openShortcutEditor = () => { - const header = windowPool.get('header'); - if (!header) return; - globalShortcut.unregisterAll(); - createFeatureWindows(header, 'shortcut-settings'); -}; - -const showSettingsWindow = (bounds) => { - if (!bounds) return; - const win = windowPool.get('settings'); - if (win && !win.isDestroyed()) { - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - settingsHideTimer = null; - } - const header = windowPool.get('header'); - const headerBounds = header?.getBounds() ?? { x: 0, y: 0 }; - const settingsBounds = win.getBounds(); - const disp = getCurrentDisplay(header); - const { x: waX, y: waY, width: waW, height: waH } = disp.workArea; - let x = Math.round(headerBounds.x + (bounds?.x ?? 0) + (bounds?.width ?? 0) / 2 - settingsBounds.width / 2); - let y = Math.round(headerBounds.y + (bounds?.y ?? 0) + (bounds?.height ?? 0) + 31); - x = Math.max(waX + 10, Math.min(waX + waW - settingsBounds.width - 10, x)); - y = Math.max(waY + 10, Math.min(waY + waH - settingsBounds.height - 10, y)); - win.setBounds({ x, y }); - win.__lockedByButton = true; - win.show(); - win.moveTop(); - win.setAlwaysOnTop(true); - } -}; - -const hideSettingsWindow = () => { - const window = windowPool.get("settings"); - if (window && !window.isDestroyed()) { - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - } - settingsHideTimer = setTimeout(() => { - if (window && !window.isDestroyed()) { - window.setAlwaysOnTop(false); - window.hide(); - } - settingsHideTimer = null; - }, 200); - - window.__lockedByButton = false; - } -}; - -const cancelHideSettingsWindow = () => { - if (settingsHideTimer) { - clearTimeout(settingsHideTimer); - settingsHideTimer = null; - } -}; const openLoginPage = () => { const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000'; @@ -474,7 +538,7 @@ function createFeatureWindows(header, namesToCreate) { case 'shortcut-settings': { const shortcutEditor = new BrowserWindow({ ...commonChildOptions, - width: 420, + width: 353, height: 720, modal: false, parent: undefined, @@ -482,36 +546,11 @@ function createFeatureWindows(header, namesToCreate) { titleBarOverlay: false, }); + shortcutEditor.setContentProtection(isContentProtectionOn); + shortcutEditor.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true}); if (process.platform === 'darwin') { - shortcutEditor.setAlwaysOnTop(true, 'screen-saver'); - } else { - shortcutEditor.setAlwaysOnTop(true); + shortcutEditor.setWindowButtonVisibility(false); } - - /* ──────────[ β‘  λ‹€λ₯Έ μ°½ 클릭 차단 ]────────── */ - const disableClicks = () => { - for (const [name, win] of windowPool) { - if (win !== shortcutEditor && !win.isDestroyed()) { - win.setIgnoreMouseEvents(true, { forward: true }); - } - } - }; - const restoreClicks = () => { - for (const [, win] of windowPool) { - if (!win.isDestroyed()) win.setIgnoreMouseEvents(false); - } - }; - - const header = windowPool.get('header'); - if (header && !header.isDestroyed()) { - const { x, y, width } = header.getBounds(); - shortcutEditor.setBounds({ x, y, width }); - } - - shortcutEditor.once('ready-to-show', () => { - disableClicks(); - shortcutEditor.show(); - }); const loadOptions = { query: { view: 'shortcut-settings' } }; if (!shouldUseLiquidGlass) { @@ -526,23 +565,11 @@ function createFeatureWindows(header, namesToCreate) { } }); } - - shortcutEditor.on('closed', () => { - restoreClicks(); - windowPool.delete('shortcut-settings'); - console.log('[Shortcuts] Re-enabled after editing.'); - shortcutsService.registerShortcuts(); - }); - - shortcutEditor.webContents.once('dom-ready', async () => { - const keybinds = await shortcutsService.loadKeybinds(); - shortcutEditor.webContents.send('load-shortcuts', keybinds); - }); + windowPool.set('shortcut-settings', shortcutEditor); if (!app.isPackaged) { shortcutEditor.webContents.openDevTools({ mode: 'detach' }); } - windowPool.set('shortcut-settings', shortcutEditor); break; } } @@ -556,6 +583,7 @@ function createFeatureWindows(header, namesToCreate) { createFeatureWindow('listen'); createFeatureWindow('ask'); createFeatureWindow('settings'); + createFeatureWindow('shortcut-settings'); } } @@ -593,35 +621,7 @@ function getDisplayById(displayId) { -function toggleAllWindowsVisibility() { - const header = windowPool.get('header'); - if (!header) return; - - if (header.isVisible()) { - lastVisibleWindows.clear(); - - windowPool.forEach((win, name) => { - if (win && !win.isDestroyed() && win.isVisible()) { - lastVisibleWindows.add(name); - } - }); - - lastVisibleWindows.forEach(name => { - if (name === 'header') return; - const win = windowPool.get(name); - if (win && !win.isDestroyed()) win.hide(); - }); - header.hide(); - - return; - } - - lastVisibleWindows.forEach(name => { - const win = windowPool.get(name); - if (win && !win.isDestroyed()) - win.show(); - }); - } + function createWindows() { @@ -690,7 +690,7 @@ function createWindows() { }); setupIpcHandlers(movementManager); - setupAnimationController(windowPool, layoutManager, movementManager); + setupWindowController(windowPool, layoutManager, movementManager); if (currentHeaderState === 'main') { createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']); @@ -850,13 +850,6 @@ const adjustWindowHeight = (sender, targetHeight) => { }; -const closeWindow = (windowName) => { - const win = windowPool.get(windowName); - if (win && !win.isDestroyed()) { - win.close(); - } -}; - module.exports = { updateLayout, createWindows, @@ -864,14 +857,11 @@ module.exports = { toggleContentProtection, resizeHeaderWindow, getContentProtectionStatus, - openShortcutEditor, showSettingsWindow, hideSettingsWindow, cancelHideSettingsWindow, openLoginPage, moveWindowStep, - closeWindow, - toggleAllWindowsVisibility, handleHeaderStateChanged, handleHeaderAnimationFinished, getHeaderPosition, From c0cf74273af99bb62150c413b21f60653c77b85b Mon Sep 17 00:00:00 2001 From: sanio Date: Tue, 15 Jul 2025 03:47:47 +0900 Subject: [PATCH 2/6] add deepgram --- package-lock.json | 78 +++++++++++++ package.json | 1 + src/features/common/ai/factory.js | 9 ++ src/features/common/ai/providers/deepgram.js | 111 ++++++++++++++++++ src/features/listen/stt/sttService.js | 117 ++++++++++++++----- 5 files changed, 286 insertions(+), 30 deletions(-) create mode 100644 src/features/common/ai/providers/deepgram.js diff --git a/package-lock.json b/package-lock.json index 7e406cf..fe4703a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "license": "GPL-3.0", "dependencies": { "@anthropic-ai/sdk": "^0.56.0", + "@deepgram/sdk": "^4.9.1", "@google/genai": "^1.8.0", "@google/generative-ai": "^0.24.1", "axios": "^1.10.0", @@ -54,6 +55,50 @@ "anthropic-ai-sdk": "bin/cli" } }, + "node_modules/@deepgram/captions": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@deepgram/captions/-/captions-1.2.0.tgz", + "integrity": "sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.10" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@deepgram/sdk": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@deepgram/sdk/-/sdk-4.9.1.tgz", + "integrity": "sha512-a30Sed6OIRldnW1U0Q0Orvhjojq4O/1pMv6ijj+3j8735LBBfAJvlJpRCjrgtzBpnkKlY6v3bV5F8qUUSpz2yg==", + "license": "MIT", + "dependencies": { + "@deepgram/captions": "^1.1.1", + "@types/node": "^18.19.39", + "cross-fetch": "^3.1.5", + "deepmerge": "^4.3.1", + "events": "^3.3.0", + "ws": "^8.17.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@deepgram/sdk/node_modules/@types/node": { + "version": "18.19.118", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.118.tgz", + "integrity": "sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug==", + "license": "MIT", + "dependencies": { + "undici-types": "~5.26.4" + } + }, + "node_modules/@deepgram/sdk/node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "license": "MIT" + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "dev": true, @@ -2992,6 +3037,15 @@ "optional": true, "peer": true }, + "node_modules/cross-fetch": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz", + "integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==", + "license": "MIT", + "dependencies": { + "node-fetch": "^2.7.0" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "dev": true, @@ -3020,6 +3074,12 @@ "node": ">=6" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "license": "MIT" + }, "node_modules/debounce-fn": { "version": "4.0.0", "license": "MIT", @@ -3078,6 +3138,15 @@ "node": ">=4.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/defaults": { "version": "1.0.4", "dev": true, @@ -3735,6 +3804,15 @@ "node": ">=6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expand-template": { "version": "2.0.3", "license": "(MIT OR WTFPL)", diff --git a/package.json b/package.json index 4ed8d95..255f90b 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "license": "GPL-3.0", "dependencies": { "@anthropic-ai/sdk": "^0.56.0", + "@deepgram/sdk": "^4.9.1", "@google/genai": "^1.8.0", "@google/generative-ai": "^0.24.1", "axios": "^1.10.0", diff --git a/src/features/common/ai/factory.js b/src/features/common/ai/factory.js index 8ccc5ec..419cfdf 100644 --- a/src/features/common/ai/factory.js +++ b/src/features/common/ai/factory.js @@ -57,6 +57,14 @@ const PROVIDERS = { ], sttModels: [], }, + 'deepgram': { + name: 'Deepgram', + handler: () => require("./providers/deepgram"), + llmModels: [], + sttModels: [ + { id: 'nova-3', name: 'Nova-3 (General)' }, + ], + }, 'ollama': { name: 'Ollama (Local)', handler: () => require("./providers/ollama"), @@ -148,6 +156,7 @@ function getProviderClass(providerId) { 'openai': 'OpenAIProvider', 'anthropic': 'AnthropicProvider', 'gemini': 'GeminiProvider', + 'deepgram': 'DeepgramProvider', 'ollama': 'OllamaProvider', 'whisper': 'WhisperProvider' }; diff --git a/src/features/common/ai/providers/deepgram.js b/src/features/common/ai/providers/deepgram.js new file mode 100644 index 0000000..c365396 --- /dev/null +++ b/src/features/common/ai/providers/deepgram.js @@ -0,0 +1,111 @@ +// providers/deepgram.js + +const { createClient, LiveTranscriptionEvents } = require('@deepgram/sdk'); +const WebSocket = require('ws'); + +/** + * Deepgram Provider 클래슀. API ν‚€ μœ νš¨μ„± 검사λ₯Ό λ‹΄λ‹Ήν•©λ‹ˆλ‹€. + */ +class DeepgramProvider { + /** + * Deepgram API ν‚€μ˜ μœ νš¨μ„±μ„ κ²€μ‚¬ν•©λ‹ˆλ‹€. + * @param {string} key - 검사할 Deepgram API ν‚€ + * @returns {Promise<{success: boolean, error?: string}>} + */ + static async validateApiKey(key) { + if (!key || typeof key !== 'string') { + return { success: false, error: 'Invalid Deepgram API key format.' }; + } + try { + // ✨ 변경점: SDK λŒ€μ‹  직접 fetch둜 APIλ₯Ό ν˜ΈμΆœν•˜μ—¬ μ•ˆμ •μ„± 확보 (openai.js 방식) + const response = await fetch('https://api.deepgram.com/v1/projects', { + headers: { 'Authorization': `Token ${key}` } + }); + + if (response.ok) { + return { success: true }; + } else { + const errorData = await response.json().catch(() => ({})); + const message = errorData.err_msg || `Validation failed with status: ${response.status}`; + return { success: false, error: message }; + } + } catch (error) { + console.error(`[DeepgramProvider] Network error during key validation:`, error); + return { success: false, error: error.message || 'A network error occurred during validation.' }; + } + } +} + +function createSTT({ + apiKey, + language = 'en-US', + sampleRate = 24000, + callbacks = {}, + }) { + const qs = new URLSearchParams({ + model: 'nova-3', + encoding: 'linear16', + sample_rate: sampleRate.toString(), + language, + smart_format: 'true', + interim_results: 'true', + channels: '1', + }); + + const url = `wss://api.deepgram.com/v1/listen?${qs}`; + + const ws = new WebSocket(url, { + headers: { Authorization: `Token ${apiKey}` }, + }); + ws.binaryType = 'arraybuffer'; + + return new Promise((resolve, reject) => { + const to = setTimeout(() => { + ws.terminate(); + reject(new Error('DG open timeout (10β€―s)')); + }, 10_000); + + ws.on('open', () => { + clearTimeout(to); + resolve({ + sendRealtimeInput: (buf) => ws.send(buf), + close: () => ws.close(1000, 'client'), + }); + }); + + ws.on('message', raw => { + let msg; + try { msg = JSON.parse(raw.toString()); } catch { return; } + if (msg.channel?.alternatives?.[0]?.transcript !== undefined) { + callbacks.onmessage?.({ provider: 'deepgram', ...msg }); + } + }); + + ws.on('close', (code, reason) => + callbacks.onclose?.({ code, reason: reason.toString() }) + ); + + ws.on('error', err => { + clearTimeout(to); + callbacks.onerror?.(err); + reject(err); + }); + }); + } + +// ... (LLM κ΄€λ ¨ Placeholder ν•¨μˆ˜λ“€μ€ κ·ΈλŒ€λ‘œ μœ μ§€) ... +function createLLM(opts) { + console.warn("[Deepgram] LLM not supported."); + return { generateContent: async () => { throw new Error("Deepgram does not support LLM functionality."); } }; +} +function createStreamingLLM(opts) { + console.warn("[Deepgram] Streaming LLM not supported."); + return { streamChat: async () => { throw new Error("Deepgram does not support Streaming LLM functionality."); } }; +} + +module.exports = { + DeepgramProvider, + createSTT, + createLLM, + createStreamingLLM +}; \ No newline at end of file diff --git a/src/features/listen/stt/sttService.js b/src/features/listen/stt/sttService.js index 9fd9e87..8acf767 100644 --- a/src/features/listen/stt/sttService.js +++ b/src/features/listen/stt/sttService.js @@ -55,17 +55,6 @@ class SttService { } } - async handleSendSystemAudioContent(data, mimeType) { - try { - await this.sendSystemAudioContent(data, mimeType); - this.sendToRenderer('system-audio-data', { data }); - return { success: true }; - } catch (error) { - console.error('Error sending system audio:', error); - return { success: false, error: error.message }; - } - } - flushMyCompletion() { const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim(); if (!this.modelInfo || !finalText) return; @@ -157,7 +146,7 @@ class SttService { console.log('[SttService] Ignoring message - session already closed'); return; } - console.log('[SttService] handleMyMessage', message); + // console.log('[SttService] handleMyMessage', message); if (this.modelInfo.provider === 'whisper') { // Whisper STT emits 'transcription' events with different structure @@ -178,10 +167,6 @@ class SttService { '(NOISE)' ]; - - - const normalizedText = finalText.toLowerCase().trim(); - const isNoise = noisePatterns.some(pattern => finalText.includes(pattern) || finalText === pattern ); @@ -232,6 +217,38 @@ class SttService { isFinal: false, timestamp: Date.now(), }); + + // Deepgram + } else if (this.modelInfo.provider === 'deepgram') { + const text = message.channel?.alternatives?.[0]?.transcript; + if (!text || text.trim().length === 0) return; + + const isFinal = message.is_final; + console.log(`[SttService-Me-Deepgram] Received: isFinal=${isFinal}, text="${text}"`); + + if (isFinal) { + // μ΅œμ’… κ²°κ³Όκ°€ λ„μ°©ν•˜λ©΄, ν˜„μž¬ 진행쀑인 λΆ€λΆ„ λ°œν™”λŠ” λΉ„μš°κ³  + // μ΅œμ’… ν…μŠ€νŠΈλ‘œ debounceλ₯Ό μ‹€ν–‰ν•©λ‹ˆλ‹€. + this.myCurrentUtterance = ''; + this.debounceMyCompletion(text); + } else { + // λΆ€λΆ„ κ²°κ³Ό(interim)인 경우, 화면에 μ‹€μ‹œκ°„μœΌλ‘œ μ—…λ°μ΄νŠΈν•©λ‹ˆλ‹€. + if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer); + this.myCompletionTimer = null; + + this.myCurrentUtterance = text; + + const continuousText = (this.myCompletionBuffer + ' ' + this.myCurrentUtterance).trim(); + + this.sendToRenderer('stt-update', { + speaker: 'Me', + text: continuousText, + isPartial: true, + isFinal: false, + timestamp: Date.now(), + }); + } + } else { const type = message.type; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; @@ -291,9 +308,6 @@ class SttService { '(NOISE)' ]; - - const normalizedText = finalText.toLowerCase().trim(); - const isNoise = noisePatterns.some(pattern => finalText.includes(pattern) || finalText === pattern ); @@ -345,6 +359,34 @@ class SttService { isFinal: false, timestamp: Date.now(), }); + + // Deepgram + } else if (this.modelInfo.provider === 'deepgram') { + const text = message.channel?.alternatives?.[0]?.transcript; + if (!text || text.trim().length === 0) return; + + const isFinal = message.is_final; + + if (isFinal) { + this.theirCurrentUtterance = ''; + this.debounceTheirCompletion(text); + } else { + if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer); + this.theirCompletionTimer = null; + + this.theirCurrentUtterance = text; + + const continuousText = (this.theirCompletionBuffer + ' ' + this.theirCurrentUtterance).trim(); + + this.sendToRenderer('stt-update', { + speaker: 'Them', + text: continuousText, + isPartial: true, + isFinal: false, + timestamp: Date.now(), + }); + } + } else { const type = message.type; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; @@ -431,10 +473,14 @@ class SttService { throw new Error('STT model info could not be retrieved.'); } - const payload = modelInfo.provider === 'gemini' - ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } - : data; - + let payload; + if (modelInfo.provider === 'gemini') { + payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }; + } else if (modelInfo.provider === 'deepgram') { + payload = Buffer.from(data, 'base64'); + } else { + payload = data; + } await this.mySttSession.sendRealtimeInput(payload); } @@ -452,10 +498,15 @@ class SttService { throw new Error('STT model info could not be retrieved.'); } - const payload = modelInfo.provider === 'gemini' - ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } - : data; - + let payload; + if (modelInfo.provider === 'gemini') { + payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }; + } else if (modelInfo.provider === 'deepgram') { + payload = Buffer.from(data, 'base64'); + } else { + payload = data; + } + await this.theirSttSession.sendRealtimeInput(payload); } @@ -547,9 +598,15 @@ class SttService { if (this.theirSttSession) { try { - const payload = modelInfo.provider === 'gemini' - ? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } } - : base64Data; + let payload; + if (modelInfo.provider === 'gemini') { + payload = { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }; + } else if (modelInfo.provider === 'deepgram') { + payload = Buffer.from(base64Data, 'base64'); + } else { + payload = base64Data; + } + await this.theirSttSession.sendRealtimeInput(payload); } catch (err) { console.error('Error sending system audio:', err.message); From 6ece74737b206977e73cce2eb6d16197429f593d Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Tue, 15 Jul 2025 14:04:34 +0900 Subject: [PATCH 3/6] Refactor: Implement local AI service management system - Add LocalAIServiceManager for centralized local AI service lifecycle management - Refactor provider settings to support local AI service configuration - Remove userModelSelections in favor of provider settings integration - Update whisper service to use new local AI management system - Implement lazy loading and auto-cleanup for local AI services - Update UI components to reflect new local AI service architecture --- src/bridge/featureBridge.js | 115 ++- src/bridge/windowBridge.js | 6 +- src/features/common/ai/providers/whisper.js | 2 +- src/features/common/config/checksums.js | 28 +- src/features/common/config/schema.js | 12 +- .../providerSettings/firebase.repository.js | 80 +- .../repositories/providerSettings/index.js | 18 + .../providerSettings/sqlite.repository.js | 114 ++- .../firebase.repository.js | 55 -- .../repositories/userModelSelections/index.js | 50 - .../userModelSelections/sqlite.repository.js | 48 - src/features/common/services/authService.js | 2 - .../common/services/localAIServiceBase.js | 308 ------ .../common/services/localProgressTracker.js | 138 --- .../common/services/modelStateService.js | 223 ++++- src/features/common/services/ollamaService.js | 896 ++++++++++++++---- .../common/services/whisperService.js | 438 ++++++++- src/features/listen/listenService.js | 8 +- src/features/settings/settingsService.js | 15 +- src/preload.js | 52 +- src/ui/app/ApiKeyHeader.js | 41 +- src/ui/settings/SettingsView.js | 182 ++-- 22 files changed, 1768 insertions(+), 1063 deletions(-) delete mode 100644 src/features/common/repositories/userModelSelections/firebase.repository.js delete mode 100644 src/features/common/repositories/userModelSelections/index.js delete mode 100644 src/features/common/repositories/userModelSelections/sqlite.repository.js delete mode 100644 src/features/common/services/localAIServiceBase.js delete mode 100644 src/features/common/services/localProgressTracker.js diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index be6d348..b5c13eb 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -1,5 +1,5 @@ // 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'); @@ -7,6 +7,8 @@ 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 windowBridge = require('./windowBridge'); +const localAIManager = require('../features/common/services/localAIManager'); const askService = require('../features/ask/askService'); const listenService = require('../features/listen/listenService'); @@ -40,6 +42,8 @@ module.exports = { ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission()); ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section)); + + //TODO: Need to Remove this ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted()); ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted()); @@ -113,6 +117,115 @@ module.exports = { ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); + // LocalAIManager 이벀트λ₯Ό λͺ¨λ“  μœˆλ„μš°μ— λΈŒλ‘œλ“œμΊμŠ€νŠΈ + localAIManager.on('install-progress', (service, data) => { + const event = { service, ...data }; + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('localai:install-progress', event); + } + }); + }); + localAIManager.on('installation-complete', (service) => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('localai:installation-complete', { service }); + } + }); + }); + localAIManager.on('error', (error) => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('localai:error-occurred', error); + } + }); + }); + // Handle error-occurred events from LocalAIManager's error handling + localAIManager.on('error-occurred', (error) => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('localai:error-occurred', error); + } + }); + }); + localAIManager.on('model-ready', (data) => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('localai:model-ready', data); + } + }); + }); + localAIManager.on('state-changed', (service, state) => { + const event = { service, ...state }; + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('localai:service-status-changed', event); + } + }); + }); + + // 주기적 μƒνƒœ 동기화 μ‹œμž‘ + localAIManager.startPeriodicSync(); + + // ModelStateService 이벀트λ₯Ό λͺ¨λ“  μœˆλ„μš°μ— λΈŒλ‘œλ“œμΊμŠ€νŠΈ + modelStateService.on('state-updated', (state) => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('model-state:updated', state); + } + }); + }); + modelStateService.on('settings-updated', () => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('settings-updated'); + } + }); + }); + modelStateService.on('force-show-apikey-header', () => { + BrowserWindow.getAllWindows().forEach(win => { + if (win && !win.isDestroyed()) { + win.webContents.send('force-show-apikey-header'); + } + }); + }); + + // LocalAI 톡합 ν•Έλ“€λŸ¬ μΆ”κ°€ + ipcMain.handle('localai:install', async (event, { service, options }) => { + return await localAIManager.installService(service, options); + }); + ipcMain.handle('localai:get-status', async (event, service) => { + return await localAIManager.getServiceStatus(service); + }); + ipcMain.handle('localai:start-service', async (event, service) => { + return await localAIManager.startService(service); + }); + ipcMain.handle('localai:stop-service', async (event, service) => { + return await localAIManager.stopService(service); + }); + ipcMain.handle('localai:install-model', async (event, { service, modelId, options }) => { + return await localAIManager.installModel(service, modelId, options); + }); + ipcMain.handle('localai:get-installed-models', async (event, service) => { + return await localAIManager.getInstalledModels(service); + }); + ipcMain.handle('localai:run-diagnostics', async (event, service) => { + return await localAIManager.runDiagnostics(service); + }); + ipcMain.handle('localai:repair-service', async (event, service) => { + return await localAIManager.repairService(service); + }); + + // μ—λŸ¬ 처리 ν•Έλ“€λŸ¬ + ipcMain.handle('localai:handle-error', async (event, { service, errorType, details }) => { + return await localAIManager.handleError(service, errorType, details); + }); + + // 전체 μƒνƒœ 쑰회 + ipcMain.handle('localai:get-all-states', async (event) => { + return await localAIManager.getAllServiceStates(); + }); + console.log('[FeatureBridge] Initialized with all feature handlers.'); }, diff --git a/src/bridge/windowBridge.js b/src/bridge/windowBridge.js index 313273c..6555049 100644 --- a/src/bridge/windowBridge.js +++ b/src/bridge/windowBridge.js @@ -1,9 +1,13 @@ // src/bridge/windowBridge.js const { ipcMain, shell } = require('electron'); -const windowManager = require('../window/windowManager'); +// BridgeλŠ” λ‹¨μˆœνžˆ IPC ν•Έλ“€λŸ¬λ₯Ό λ“±λ‘ν•˜λŠ” μ—­ν• λ§Œ 함 (λΉ„μ¦ˆλ‹ˆμŠ€ 둜직 μ—†μŒ) module.exports = { initialize() { + // initialize μ‹œμ μ— windowManagerλ₯Ό requireν•˜μ—¬ circular dependency 문제 ν•΄κ²° + const windowManager = require('../window/windowManager'); + + // κΈ°μ‘΄ IPC ν•Έλ“€λŸ¬λ“€ ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection()); ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args)); ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus()); diff --git a/src/features/common/ai/providers/whisper.js b/src/features/common/ai/providers/whisper.js index 58cd666..6dca6be 100644 --- a/src/features/common/ai/providers/whisper.js +++ b/src/features/common/ai/providers/whisper.js @@ -41,7 +41,7 @@ class WhisperSTTSession extends EventEmitter { startProcessingLoop() { this.processingInterval = setInterval(async () => { - const minBufferSize = 24000 * 2 * 0.15; + const minBufferSize = 16000 * 2 * 0.15; if (this.audioBuffer.length >= minBufferSize && !this.process) { console.log(`[WhisperSTT-${this.sessionId}] Processing audio chunk, buffer size: ${this.audioBuffer.length}`); await this.processAudioChunk(); diff --git a/src/features/common/config/checksums.js b/src/features/common/config/checksums.js index ff903cc..216c31f 100644 --- a/src/features/common/config/checksums.js +++ b/src/features/common/config/checksums.js @@ -2,41 +2,49 @@ const DOWNLOAD_CHECKSUMS = { ollama: { dmg: { url: 'https://ollama.com/download/Ollama.dmg', - sha256: null // To be updated with actual checksum + sha256: null // TODO: μ‹€μ œ 체크섬 μΆ”κ°€ ν•„μš” - null일 경우 체크섬 검증 μŠ€ν‚΅λ¨ }, exe: { url: 'https://ollama.com/download/OllamaSetup.exe', - sha256: null // To be updated with actual checksum + sha256: null // TODO: μ‹€μ œ 체크섬 μΆ”κ°€ ν•„μš” - null일 경우 체크섬 검증 μŠ€ν‚΅λ¨ + }, + linux: { + url: 'curl -fsSL https://ollama.com/install.sh | sh', + sha256: null // TODO: μ‹€μ œ 체크섬 μΆ”κ°€ ν•„μš” - null일 경우 체크섬 검증 μŠ€ν‚΅λ¨ } }, whisper: { models: { 'whisper-tiny': { - url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin', + url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-tiny.bin', sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21' }, 'whisper-base': { - url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin', + url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-base.bin', sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe' }, 'whisper-small': { - url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin', + url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-small.bin', sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b' }, 'whisper-medium': { - url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin', + url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-medium.bin', sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208' } }, binaries: { 'v1.7.6': { + mac: { + url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-mac-x64.zip', + sha256: null // TODO: μ‹€μ œ 체크섬 μΆ”κ°€ ν•„μš” - null일 경우 체크섬 검증 μŠ€ν‚΅λ¨ + }, windows: { - url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip', - sha256: null // To be updated with actual checksum + url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip', + sha256: null // TODO: μ‹€μ œ 체크섬 μΆ”κ°€ ν•„μš” - null일 경우 체크섬 검증 μŠ€ν‚΅λ¨ }, linux: { - url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz', - sha256: null // To be updated with actual checksum + url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz', + sha256: null // TODO: μ‹€μ œ 체크섬 μΆ”κ°€ ν•„μš” - null일 경우 체크섬 검증 μŠ€ν‚΅λ¨ } } } diff --git a/src/features/common/config/schema.js b/src/features/common/config/schema.js index ad5c3b6..087e79b 100644 --- a/src/features/common/config/schema.js +++ b/src/features/common/config/schema.js @@ -96,21 +96,13 @@ const LATEST_SCHEMA = { { name: 'api_key', type: 'TEXT' }, { name: 'selected_llm_model', type: 'TEXT' }, { name: 'selected_stt_model', type: 'TEXT' }, + { name: 'is_active_llm', type: 'INTEGER DEFAULT 0' }, + { name: 'is_active_stt', type: 'INTEGER DEFAULT 0' }, { name: 'created_at', type: 'INTEGER' }, { name: 'updated_at', type: 'INTEGER' } ], constraints: ['PRIMARY KEY (uid, provider)'] }, - user_model_selections: { - columns: [ - { name: 'uid', type: 'TEXT PRIMARY KEY' }, - { name: 'selected_llm_provider', type: 'TEXT' }, - { name: 'selected_llm_model', type: 'TEXT' }, - { name: 'selected_stt_provider', type: 'TEXT' }, - { name: 'selected_stt_model', type: 'TEXT' }, - { name: 'updated_at', type: 'INTEGER' } - ] - }, shortcuts: { columns: [ { name: 'action', type: 'TEXT PRIMARY KEY' }, diff --git a/src/features/common/repositories/providerSettings/firebase.repository.js b/src/features/common/repositories/providerSettings/firebase.repository.js index f7fed8f..71d3575 100644 --- a/src/features/common/repositories/providerSettings/firebase.repository.js +++ b/src/features/common/repositories/providerSettings/firebase.repository.js @@ -74,10 +74,88 @@ async function removeAllByUid(uid) { } } +// Get active provider for a specific type (llm or stt) +async function getActiveProvider(uid, type) { + try { + const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; + const q = query(providerSettingsCol(), + where('uid', '==', uid), + where(column, '==', true) + ); + const querySnapshot = await getDocs(q); + + if (querySnapshot.empty) { + return null; + } + + const doc = querySnapshot.docs[0]; + return { id: doc.id, ...doc.data() }; + } catch (error) { + console.error('[ProviderSettings Firebase] Error getting active provider:', error); + return null; + } +} + +// Set active provider for a specific type +async function setActiveProvider(uid, provider, type) { + try { + const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; + + // First, deactivate all providers for this type + const allSettings = await getAllByUid(uid); + const updatePromises = allSettings.map(setting => { + const docRef = doc(providerSettingsCol(), setting.id); + return setDoc(docRef, { [column]: false }, { merge: true }); + }); + await Promise.all(updatePromises); + + // Then activate the specified provider + if (provider) { + const docRef = doc(providerSettingsCol(), `${uid}_${provider}`); + await setDoc(docRef, { [column]: true }, { merge: true }); + } + + return { success: true }; + } catch (error) { + console.error('[ProviderSettings Firebase] Error setting active provider:', error); + throw error; + } +} + +// Get all active settings (both llm and stt) +async function getActiveSettings(uid) { + try { + // Firebase doesn't support OR queries in this way, so we'll get all settings and filter + const allSettings = await getAllByUid(uid); + + const activeSettings = { + llm: null, + stt: null + }; + + allSettings.forEach(setting => { + if (setting.is_active_llm) { + activeSettings.llm = setting; + } + if (setting.is_active_stt) { + activeSettings.stt = setting; + } + }); + + return activeSettings; + } catch (error) { + console.error('[ProviderSettings Firebase] Error getting active settings:', error); + return { llm: null, stt: null }; + } +} + module.exports = { getByProvider, getAllByUid, upsert, remove, - removeAllByUid + removeAllByUid, + getActiveProvider, + setActiveProvider, + getActiveSettings }; \ No newline at end of file diff --git a/src/features/common/repositories/providerSettings/index.js b/src/features/common/repositories/providerSettings/index.js index d4fb384..fa681ed 100644 --- a/src/features/common/repositories/providerSettings/index.js +++ b/src/features/common/repositories/providerSettings/index.js @@ -56,6 +56,24 @@ const providerSettingsRepositoryAdapter = { const repo = getBaseRepository(); const uid = authService.getCurrentUserId(); return await repo.removeAllByUid(uid); + }, + + async getActiveProvider(type) { + const repo = getBaseRepository(); + const uid = authService.getCurrentUserId(); + return await repo.getActiveProvider(uid, type); + }, + + async setActiveProvider(provider, type) { + const repo = getBaseRepository(); + const uid = authService.getCurrentUserId(); + return await repo.setActiveProvider(uid, provider, type); + }, + + async getActiveSettings() { + const repo = getBaseRepository(); + const uid = authService.getCurrentUserId(); + return await repo.getActiveSettings(uid); } }; diff --git a/src/features/common/repositories/providerSettings/sqlite.repository.js b/src/features/common/repositories/providerSettings/sqlite.repository.js index 1967890..58932f1 100644 --- a/src/features/common/repositories/providerSettings/sqlite.repository.js +++ b/src/features/common/repositories/providerSettings/sqlite.repository.js @@ -1,15 +1,15 @@ const sqliteClient = require('../../services/sqliteClient'); -const encryptionService = require('../../services/encryptionService'); function getByProvider(uid, provider) { const db = sqliteClient.getDb(); const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?'); const result = stmt.get(uid, provider) || null; - if (result && result.api_key) { - // Decrypt API key if it exists - result.api_key = encryptionService.decrypt(result.api_key); - } + // if (result && result.api_key) { + // // Decrypt API key if it exists + // result.api_key = result.api_key; + // } + return result; } @@ -22,40 +22,49 @@ function getAllByUid(uid) { // Decrypt API keys for all results return results.map(result => { if (result.api_key) { - result.api_key = encryptionService.decrypt(result.api_key); + result.api_key = result.api_key; } return result; }); } function upsert(uid, provider, settings) { + // Validate: prevent direct setting of active status + if (settings.is_active_llm || settings.is_active_stt) { + console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.'); + } + const db = sqliteClient.getDb(); // Encrypt API key if it exists - const encryptedSettings = { ...settings }; - if (encryptedSettings.api_key) { - encryptedSettings.api_key = encryptionService.encrypt(encryptedSettings.api_key); - } + // const encryptedSettings = { ...settings }; + // if (encryptedSettings.api_key) { + // encryptedSettings.api_key = encryptedSettings.api_key; + // } // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE) const stmt = db.prepare(` - INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(uid, provider) DO UPDATE SET api_key = excluded.api_key, selected_llm_model = excluded.selected_llm_model, selected_stt_model = excluded.selected_stt_model, + -- is_active_llm and is_active_stt are NOT updated here + -- Use setActiveProvider() to change active status updated_at = excluded.updated_at `); const result = stmt.run( uid, provider, - encryptedSettings.api_key || null, - encryptedSettings.selected_llm_model || null, - encryptedSettings.selected_stt_model || null, - encryptedSettings.created_at || Date.now(), - encryptedSettings.updated_at + settings.api_key || null, + settings.selected_llm_model || null, + settings.selected_stt_model || null, + 0, // is_active_llm - always 0, use setActiveProvider to activate + 0, // is_active_stt - always 0, use setActiveProvider to activate + settings.created_at || Date.now(), + settings.updated_at ); return { changes: result.changes }; @@ -75,10 +84,79 @@ function removeAllByUid(uid) { return { changes: result.changes }; } +// Get active provider for a specific type (llm or stt) +function getActiveProvider(uid, type) { + const db = sqliteClient.getDb(); + const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; + const stmt = db.prepare(`SELECT * FROM provider_settings WHERE uid = ? AND ${column} = 1`); + const result = stmt.get(uid) || null; + + if (result && result.api_key) { + result.api_key = result.api_key; + } + + return result; +} + +// Set active provider for a specific type +function setActiveProvider(uid, provider, type) { + const db = sqliteClient.getDb(); + const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; + + // Start transaction to ensure only one provider is active + db.transaction(() => { + // First, deactivate all providers for this type + const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0 WHERE uid = ?`); + deactivateStmt.run(uid); + + // Then activate the specified provider + if (provider) { + const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE uid = ? AND provider = ?`); + activateStmt.run(uid, provider); + } + })(); + + return { success: true }; +} + +// Get all active settings (both llm and stt) +function getActiveSettings(uid) { + const db = sqliteClient.getDb(); + const stmt = db.prepare(` + SELECT * FROM provider_settings + WHERE uid = ? AND (is_active_llm = 1 OR is_active_stt = 1) + ORDER BY provider + `); + const results = stmt.all(uid); + + // Decrypt API keys and organize by type + const activeSettings = { + llm: null, + stt: null + }; + + results.forEach(result => { + if (result.api_key) { + result.api_key = result.api_key; + } + if (result.is_active_llm) { + activeSettings.llm = result; + } + if (result.is_active_stt) { + activeSettings.stt = result; + } + }); + + return activeSettings; +} + module.exports = { getByProvider, getAllByUid, upsert, remove, - removeAllByUid + removeAllByUid, + getActiveProvider, + setActiveProvider, + getActiveSettings }; \ No newline at end of file diff --git a/src/features/common/repositories/userModelSelections/firebase.repository.js b/src/features/common/repositories/userModelSelections/firebase.repository.js deleted file mode 100644 index 58f879b..0000000 --- a/src/features/common/repositories/userModelSelections/firebase.repository.js +++ /dev/null @@ -1,55 +0,0 @@ -const { collection, doc, getDoc, setDoc, deleteDoc } = require('firebase/firestore'); -const { getFirestoreInstance: getFirestore } = require('../../services/firebaseClient'); -const { createEncryptedConverter } = require('../firestoreConverter'); - -// Create encrypted converter for user model selections -const userModelSelectionsConverter = createEncryptedConverter([ - 'selected_llm_provider', - 'selected_llm_model', - 'selected_stt_provider', - 'selected_stt_model' -]); - -function userModelSelectionsCol() { - const db = getFirestore(); - return collection(db, 'user_model_selections').withConverter(userModelSelectionsConverter); -} - -async function get(uid) { - try { - const docRef = doc(userModelSelectionsCol(), uid); - const docSnap = await getDoc(docRef); - return docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null; - } catch (error) { - console.error('[UserModelSelections Firebase] Error getting user model selections:', error); - return null; - } -} - -async function upsert(uid, selections) { - try { - const docRef = doc(userModelSelectionsCol(), uid); - await setDoc(docRef, selections, { merge: true }); - return { changes: 1 }; - } catch (error) { - console.error('[UserModelSelections Firebase] Error upserting user model selections:', error); - throw error; - } -} - -async function remove(uid) { - try { - const docRef = doc(userModelSelectionsCol(), uid); - await deleteDoc(docRef); - return { changes: 1 }; - } catch (error) { - console.error('[UserModelSelections Firebase] Error removing user model selections:', error); - throw error; - } -} - -module.exports = { - get, - upsert, - remove -}; \ No newline at end of file diff --git a/src/features/common/repositories/userModelSelections/index.js b/src/features/common/repositories/userModelSelections/index.js deleted file mode 100644 index e886af0..0000000 --- a/src/features/common/repositories/userModelSelections/index.js +++ /dev/null @@ -1,50 +0,0 @@ -const firebaseRepository = require('./firebase.repository'); -const sqliteRepository = require('./sqlite.repository'); - -let authService = null; - -function setAuthService(service) { - authService = service; -} - -function getBaseRepository() { - if (!authService) { - throw new Error('AuthService not set for userModelSelections repository'); - } - - const user = authService.getCurrentUser(); - return user.isLoggedIn ? firebaseRepository : sqliteRepository; -} - -const userModelSelectionsRepositoryAdapter = { - async get() { - const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.get(uid); - }, - - async upsert(selections) { - const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - const now = Date.now(); - - const selectionsWithMeta = { - ...selections, - uid, - updated_at: now - }; - - return await repo.upsert(uid, selectionsWithMeta); - }, - - async remove() { - const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.remove(uid); - } -}; - -module.exports = { - ...userModelSelectionsRepositoryAdapter, - setAuthService -}; \ No newline at end of file diff --git a/src/features/common/repositories/userModelSelections/sqlite.repository.js b/src/features/common/repositories/userModelSelections/sqlite.repository.js deleted file mode 100644 index abd38df..0000000 --- a/src/features/common/repositories/userModelSelections/sqlite.repository.js +++ /dev/null @@ -1,48 +0,0 @@ -const sqliteClient = require('../../services/sqliteClient'); - -function get(uid) { - const db = sqliteClient.getDb(); - const stmt = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?'); - return stmt.get(uid) || null; -} - -function upsert(uid, selections) { - const db = sqliteClient.getDb(); - - // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE) - const stmt = db.prepare(` - INSERT INTO user_model_selections (uid, selected_llm_provider, selected_llm_model, - selected_stt_provider, selected_stt_model, updated_at) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT(uid) DO UPDATE SET - selected_llm_provider = excluded.selected_llm_provider, - selected_llm_model = excluded.selected_llm_model, - selected_stt_provider = excluded.selected_stt_provider, - selected_stt_model = excluded.selected_stt_model, - updated_at = excluded.updated_at - `); - - const result = stmt.run( - uid, - selections.selected_llm_provider || null, - selections.selected_llm_model || null, - selections.selected_stt_provider || null, - selections.selected_stt_model || null, - selections.updated_at - ); - - return { changes: result.changes }; -} - -function remove(uid) { - const db = sqliteClient.getDb(); - const stmt = db.prepare('DELETE FROM user_model_selections WHERE uid = ?'); - const result = stmt.run(uid); - return { changes: result.changes }; -} - -module.exports = { - get, - upsert, - remove -}; \ No newline at end of file diff --git a/src/features/common/services/authService.js b/src/features/common/services/authService.js index face151..2969418 100644 --- a/src/features/common/services/authService.js +++ b/src/features/common/services/authService.js @@ -6,7 +6,6 @@ const encryptionService = require('./encryptionService'); const migrationService = require('./migrationService'); const sessionRepository = require('../repositories/session'); const providerSettingsRepository = require('../repositories/providerSettings'); -const userModelSelectionsRepository = require('../repositories/userModelSelections'); async function getVirtualKeyByEmail(email, idToken) { if (!idToken) { @@ -48,7 +47,6 @@ class AuthService { sessionRepository.setAuthService(this); providerSettingsRepository.setAuthService(this); - userModelSelectionsRepository.setAuthService(this); } initialize() { diff --git a/src/features/common/services/localAIServiceBase.js b/src/features/common/services/localAIServiceBase.js deleted file mode 100644 index cbbca3b..0000000 --- a/src/features/common/services/localAIServiceBase.js +++ /dev/null @@ -1,308 +0,0 @@ -const { exec } = require('child_process'); -const { promisify } = require('util'); -const { EventEmitter } = require('events'); -const { BrowserWindow } = require('electron'); -const path = require('path'); -const os = require('os'); -const https = require('https'); -const fs = require('fs'); -const crypto = require('crypto'); - -const execAsync = promisify(exec); - -class LocalAIServiceBase extends EventEmitter { - constructor(serviceName) { - super(); - this.serviceName = serviceName; - this.baseUrl = null; - this.installationProgress = new Map(); - } - - // λͺ¨λ“  μœˆλ„μš°μ— 이벀트 λΈŒλ‘œλ“œμΊμŠ€νŠΈ - _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); - } - } - }); - } - - getPlatform() { - return process.platform; - } - - async checkCommand(command) { - try { - const platform = this.getPlatform(); - const checkCmd = platform === 'win32' ? 'where' : 'which'; - const { stdout } = await execAsync(`${checkCmd} ${command}`); - return stdout.trim(); - } catch (error) { - return null; - } - } - - async isInstalled() { - throw new Error('isInstalled() must be implemented by subclass'); - } - - async isServiceRunning() { - throw new Error('isServiceRunning() must be implemented by subclass'); - } - - async startService() { - throw new Error('startService() must be implemented by subclass'); - } - - async stopService() { - throw new Error('stopService() must be implemented by subclass'); - } - - async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) { - for (let i = 0; i < maxAttempts; i++) { - if (await checkFn()) { - console.log(`[${this.serviceName}] Service is ready`); - return true; - } - await new Promise(resolve => setTimeout(resolve, delayMs)); - } - throw new Error(`${this.serviceName} service failed to start within timeout`); - } - - getInstallProgress(modelName) { - return this.installationProgress.get(modelName) || 0; - } - - setInstallProgress(modelName, progress) { - this.installationProgress.set(modelName, progress); - // 각 μ„œλΉ„μŠ€μ—μ„œ 직접 λΈŒλ‘œλ“œμΊμŠ€νŠΈν•˜λ„λ‘ λ³€κ²½ - } - - clearInstallProgress(modelName) { - this.installationProgress.delete(modelName); - } - - async autoInstall(onProgress) { - const platform = this.getPlatform(); - console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`); - - try { - switch(platform) { - case 'darwin': - return await this.installMacOS(onProgress); - case 'win32': - return await this.installWindows(onProgress); - case 'linux': - return await this.installLinux(); - default: - throw new Error(`Unsupported platform: ${platform}`); - } - } catch (error) { - console.error(`[${this.serviceName}] Auto-installation failed:`, error); - throw error; - } - } - - async installMacOS() { - throw new Error('installMacOS() must be implemented by subclass'); - } - - async installWindows() { - throw new Error('installWindows() must be implemented by subclass'); - } - - async installLinux() { - throw new Error('installLinux() must be implemented by subclass'); - } - - // parseProgress method removed - using proper REST API now - - async shutdown(force = false) { - console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`); - - const isRunning = await this.isServiceRunning(); - if (!isRunning) { - console.log(`[${this.serviceName}] Service not running, nothing to shutdown`); - return true; - } - - const platform = this.getPlatform(); - - try { - switch(platform) { - case 'darwin': - return await this.shutdownMacOS(force); - case 'win32': - return await this.shutdownWindows(force); - case 'linux': - return await this.shutdownLinux(force); - default: - console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`); - return false; - } - } catch (error) { - console.error(`[${this.serviceName}] Error during shutdown:`, error); - return false; - } - } - - async shutdownMacOS(force) { - throw new Error('shutdownMacOS() must be implemented by subclass'); - } - - async shutdownWindows(force) { - throw new Error('shutdownWindows() must be implemented by subclass'); - } - - async shutdownLinux(force) { - throw new Error('shutdownLinux() must be implemented by subclass'); - } - - async downloadFile(url, destination, options = {}) { - const { - onProgress = null, - headers = { 'User-Agent': 'Glass-App' }, - timeout = 300000, // 5 minutes default - modelId = null // λͺ¨λΈ IDλ₯Ό μœ„ν•œ μΆ”κ°€ μ˜΅μ…˜ - } = options; - - return new Promise((resolve, reject) => { - const file = fs.createWriteStream(destination); - let downloadedSize = 0; - let totalSize = 0; - - const request = https.get(url, { headers }, (response) => { - // Handle redirects (301, 302, 307, 308) - if ([301, 302, 307, 308].includes(response.statusCode)) { - file.close(); - fs.unlink(destination, () => {}); - - if (!response.headers.location) { - reject(new Error('Redirect without location header')); - return; - } - - console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`); - this.downloadFile(response.headers.location, destination, options) - .then(resolve) - .catch(reject); - return; - } - - if (response.statusCode !== 200) { - file.close(); - fs.unlink(destination, () => {}); - reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)); - return; - } - - totalSize = parseInt(response.headers['content-length'], 10) || 0; - - response.on('data', (chunk) => { - downloadedSize += chunk.length; - - if (totalSize > 0) { - const progress = Math.round((downloadedSize / totalSize) * 100); - - // 이벀트 기반 μ§„ν–‰λ₯  λ³΄κ³ λŠ” 각 μ„œλΉ„μŠ€μ—μ„œ 직접 처리 - - // κΈ°μ‘΄ 콜백 지원 (ν˜Έν™˜μ„± μœ μ§€) - if (onProgress) { - onProgress(progress, downloadedSize, totalSize); - } - } - }); - - response.pipe(file); - - file.on('finish', () => { - file.close(() => { - // download-complete μ΄λ²€νŠΈλŠ” 각 μ„œλΉ„μŠ€μ—μ„œ 직접 처리 - resolve({ success: true, size: downloadedSize }); - }); - }); - }); - - request.on('timeout', () => { - request.destroy(); - file.close(); - fs.unlink(destination, () => {}); - reject(new Error('Download timeout')); - }); - - request.on('error', (err) => { - file.close(); - fs.unlink(destination, () => {}); - this.emit('download-error', { url, error: err, modelId }); - reject(err); - }); - - request.setTimeout(timeout); - - file.on('error', (err) => { - fs.unlink(destination, () => {}); - reject(err); - }); - }); - } - - async downloadWithRetry(url, destination, 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, - modelId - }); - - if (expectedChecksum) { - const isValid = await this.verifyChecksum(destination, expectedChecksum); - if (!isValid) { - fs.unlinkSync(destination); - throw new Error('Checksum verification failed'); - } - console.log(`[${this.serviceName}] Checksum verified successfully`); - } - - return result; - } catch (error) { - if (attempt === maxRetries) { - // download-error μ΄λ²€νŠΈλŠ” 각 μ„œλΉ„μŠ€μ—μ„œ 직접 처리 - throw error; - } - - console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`); - await new Promise(resolve => setTimeout(resolve, retryDelay * attempt)); - } - } - } - - async verifyChecksum(filePath, expectedChecksum) { - return new Promise((resolve, reject) => { - const hash = crypto.createHash('sha256'); - const stream = fs.createReadStream(filePath); - - stream.on('data', (data) => hash.update(data)); - stream.on('end', () => { - const fileChecksum = hash.digest('hex'); - console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`); - console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`); - resolve(fileChecksum === expectedChecksum); - }); - stream.on('error', reject); - }); - } -} - -module.exports = LocalAIServiceBase; \ No newline at end of file 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 c331958..46242ae 100644 --- a/src/features/common/services/modelStateService.js +++ b/src/features/common/services/modelStateService.js @@ -1,11 +1,9 @@ const Store = require('electron-store'); const fetch = require('node-fetch'); const { EventEmitter } = require('events'); -const { BrowserWindow } = require('electron'); const { PROVIDERS, getProviderClass } = require('../ai/factory'); const encryptionService = require('./encryptionService'); const providerSettingsRepository = require('../repositories/providerSettings'); -const userModelSelectionsRepository = require('../repositories/userModelSelections'); // Import authService directly (singleton) const authService = require('./authService'); @@ -19,25 +17,54 @@ class ModelStateService extends EventEmitter { this.hasMigrated = false; } - // λͺ¨λ“  μœˆλ„μš°μ— 이벀트 λΈŒλ‘œλ“œμΊμŠ€νŠΈ - _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); - } - } - }); - } async initialize() { console.log('[ModelStateService] Initializing...'); await this._loadStateForCurrentUser(); + + // LocalAI μƒνƒœ λ³€κ²½ 이벀트 ꡬ독 + this.setupLocalAIStateSync(); + console.log('[ModelStateService] Initialization complete'); } + setupLocalAIStateSync() { + // LocalAI μ„œλΉ„μŠ€ μƒνƒœ λ³€κ²½ 감지 + // LocalAIManagerμ—μ„œ 직접 이벀트λ₯Ό λ°›μ•„ 처리 + const localAIManager = require('./localAIManager'); + localAIManager.on('state-changed', (service, status) => { + this.handleLocalAIStateChange(service, status); + }); + } + + handleLocalAIStateChange(service, state) { + console.log(`[ModelStateService] LocalAI state changed: ${service}`, state); + + // Ollama의 경우 λ‘œλ“œλœ λͺ¨λΈ 정보도 처리 + if (service === 'ollama' && state.loadedModels) { + console.log(`[ModelStateService] Ollama loaded models: ${state.loadedModels.join(', ')}`); + + // μ„ νƒλœ λͺ¨λΈμ΄ λ©”λͺ¨λ¦¬μ—μ„œ μ–Έλ‘œλ“œλ˜μ—ˆλŠ”μ§€ 확인 + const selectedLLM = this.state.selectedModels.llm; + if (selectedLLM && this.getProviderForModel('llm', selectedLLM) === 'ollama') { + if (!state.loadedModels.includes(selectedLLM)) { + console.log(`[ModelStateService] Selected model ${selectedLLM} is not loaded in memory`); + // ν•„μš”μ‹œ μžλ™ μ›Œλ°μ—… 트리거 + this._triggerAutoWarmUp(); + } + } + } + + // μžλ™ 선택 μž¬μ‹€ν–‰ (ν•„μš”μ‹œ) + if (!state.installed || !state.running) { + const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : []; + this._autoSelectAvailableModels(types); + } + + // UI μ—…λ°μ΄νŠΈ μ•Œλ¦Ό + this.emit('state-updated', this.state); + } + _logCurrentSelection() { const llmModel = this.state.selectedModels.llm; const sttModel = this.state.selectedModels.stt; @@ -86,6 +113,66 @@ class ModelStateService extends EventEmitter { }); } + async _migrateUserModelSelections() { + console.log('[ModelStateService] Checking for user_model_selections migration...'); + const userId = this.authService.getCurrentUserId(); + + try { + // Check if user_model_selections table exists + const sqliteClient = require('./sqliteClient'); + const db = sqliteClient.getDb(); + + const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_model_selections'").get(); + + if (!tableExists) { + console.log('[ModelStateService] user_model_selections table does not exist, skipping migration'); + return; + } + + // Get existing user_model_selections data + const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId); + + if (!selections) { + console.log('[ModelStateService] No user_model_selections data to migrate'); + return; + } + + console.log('[ModelStateService] Found user_model_selections data, migrating to provider_settings...'); + + // Migrate LLM selection + if (selections.llm_model) { + const llmProvider = this.getProviderForModel('llm', selections.llm_model); + if (llmProvider) { + await providerSettingsRepository.upsert(llmProvider, { + selected_llm_model: selections.llm_model + }); + await providerSettingsRepository.setActiveProvider(llmProvider, 'llm'); + console.log(`[ModelStateService] Migrated LLM: ${selections.llm_model} (provider: ${llmProvider})`); + } + } + + // Migrate STT selection + if (selections.stt_model) { + const sttProvider = this.getProviderForModel('stt', selections.stt_model); + if (sttProvider) { + await providerSettingsRepository.upsert(sttProvider, { + selected_stt_model: selections.stt_model + }); + await providerSettingsRepository.setActiveProvider(sttProvider, 'stt'); + console.log(`[ModelStateService] Migrated STT: ${selections.stt_model} (provider: ${sttProvider})`); + } + } + + // Delete the migrated data from user_model_selections + db.prepare('DELETE FROM user_model_selections WHERE uid = ?').run(userId); + console.log('[ModelStateService] user_model_selections migration completed'); + + } catch (error) { + console.error('[ModelStateService] user_model_selections migration failed:', error); + // Don't throw - continue with normal operation + } + } + async _migrateFromElectronStore() { console.log('[ModelStateService] Starting migration from electron-store to database...'); const userId = this.authService.getCurrentUserId(); @@ -115,17 +202,26 @@ class ModelStateService extends EventEmitter { } // Migrate global model selections - if (selectedModels.llm || selectedModels.stt) { - const llmProvider = selectedModels.llm ? this.getProviderForModel('llm', selectedModels.llm) : null; - const sttProvider = selectedModels.stt ? this.getProviderForModel('stt', selectedModels.stt) : null; - - await userModelSelectionsRepository.upsert({ - selected_llm_provider: llmProvider, - selected_llm_model: selectedModels.llm, - selected_stt_provider: sttProvider, - selected_stt_model: selectedModels.stt - }); - console.log('[ModelStateService] Migrated global model selections'); + if (selectedModels.llm) { + const llmProvider = this.getProviderForModel('llm', selectedModels.llm); + if (llmProvider) { + await providerSettingsRepository.upsert(llmProvider, { + selected_llm_model: selectedModels.llm + }); + await providerSettingsRepository.setActiveProvider(llmProvider, 'llm'); + console.log(`[ModelStateService] Migrated LLM model selection: ${selectedModels.llm}`); + } + } + + if (selectedModels.stt) { + const sttProvider = this.getProviderForModel('stt', selectedModels.stt); + if (sttProvider) { + await providerSettingsRepository.upsert(sttProvider, { + selected_stt_model: selectedModels.stt + }); + await providerSettingsRepository.setActiveProvider(sttProvider, 'stt'); + console.log(`[ModelStateService] Migrated STT model selection: ${selectedModels.stt}`); + } } // Mark migration as complete by removing legacy data @@ -159,11 +255,11 @@ class ModelStateService extends EventEmitter { } } - // Load global model selections - const modelSelections = await userModelSelectionsRepository.get(); + // Load active model selections from provider settings + const activeSettings = await providerSettingsRepository.getActiveSettings(); const selectedModels = { - llm: modelSelections?.selected_llm_model || null, - stt: modelSelections?.selected_stt_model || null + llm: activeSettings.llm?.selected_llm_model || null, + stt: activeSettings.stt?.selected_stt_model || null }; this.state = { @@ -197,6 +293,9 @@ class ModelStateService extends EventEmitter { // Initialize encryption service for current user await encryptionService.initializeKey(userId); + // Check for user_model_selections migration first + await this._migrateUserModelSelections(); + // Try to load from database first await this._loadStateFromDatabase(); @@ -232,17 +331,38 @@ class ModelStateService extends EventEmitter { } } - // Save global model selections - const llmProvider = this.state.selectedModels.llm ? this.getProviderForModel('llm', this.state.selectedModels.llm) : null; - const sttProvider = this.state.selectedModels.stt ? this.getProviderForModel('stt', this.state.selectedModels.stt) : null; + // Save model selections and update active providers + const llmModel = this.state.selectedModels.llm; + const sttModel = this.state.selectedModels.stt; - if (llmProvider || sttProvider || this.state.selectedModels.llm || this.state.selectedModels.stt) { - await userModelSelectionsRepository.upsert({ - selected_llm_provider: llmProvider, - selected_llm_model: this.state.selectedModels.llm, - selected_stt_provider: sttProvider, - selected_stt_model: this.state.selectedModels.stt - }); + if (llmModel) { + const llmProvider = this.getProviderForModel('llm', llmModel); + if (llmProvider) { + // Update the provider's selected model + await providerSettingsRepository.upsert(llmProvider, { + selected_llm_model: llmModel + }); + // Set as active LLM provider + await providerSettingsRepository.setActiveProvider(llmProvider, 'llm'); + } + } else { + // Deactivate all LLM providers if no model selected + await providerSettingsRepository.setActiveProvider(null, 'llm'); + } + + if (sttModel) { + const sttProvider = this.getProviderForModel('stt', sttModel); + if (sttProvider) { + // Update the provider's selected model + await providerSettingsRepository.upsert(sttProvider, { + selected_stt_model: sttModel + }); + // Set as active STT provider + await providerSettingsRepository.setActiveProvider(sttProvider, 'stt'); + } + } else { + // Deactivate all STT providers if no model selected + await providerSettingsRepository.setActiveProvider(null, 'stt'); } console.log(`[ModelStateService] State saved to database for user: ${userId}`); @@ -344,8 +464,8 @@ class ModelStateService extends EventEmitter { this._autoSelectAvailableModels([]); - this._broadcastToAllWindows('model-state:updated', this.state); - this._broadcastToAllWindows('settings-updated'); + this.emit('state-updated', this.state); + this.emit('settings-updated'); } getApiKey(provider) { @@ -363,8 +483,8 @@ class ModelStateService extends EventEmitter { await providerSettingsRepository.remove(provider); await this._saveState(); this._autoSelectAvailableModels([]); - this._broadcastToAllWindows('model-state:updated', this.state); - this._broadcastToAllWindows('settings-updated'); + this.emit('state-updated', this.state); + this.emit('settings-updated'); return true; } return false; @@ -506,12 +626,21 @@ class ModelStateService extends EventEmitter { if (type === 'llm' && modelId && modelId !== previousModelId) { const provider = this.getProviderForModel('llm', modelId); if (provider === 'ollama') { - this._autoWarmUpOllamaModel(modelId, previousModelId); + const localAIManager = require('./localAIManager'); + if (localAIManager) { + console.log('[ModelStateService] Triggering Ollama model warm-up via LocalAIManager'); + localAIManager.warmUpModel(modelId).catch(error => { + console.warn('[ModelStateService] Model warm-up failed:', error); + }); + } else { + // fallback to old method + this._autoWarmUpOllamaModel(modelId, previousModelId); + } } } - this._broadcastToAllWindows('model-state:updated', this.state); - this._broadcastToAllWindows('settings-updated'); + this.emit('state-updated', this.state); + this.emit('settings-updated'); return true; } @@ -578,7 +707,7 @@ class ModelStateService extends EventEmitter { if (success) { const selectedModels = this.getSelectedModels(); if (!selectedModels.llm || !selectedModels.stt) { - this._broadcastToAllWindows('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 f631b09..b4830a0 100644 --- a/src/features/common/services/ollamaService.js +++ b/src/features/common/services/ollamaService.js @@ -1,56 +1,100 @@ -const { spawn } = require('child_process'); +const { EventEmitter } = require('events'); +const { spawn, exec } = require('child_process'); const { promisify } = require('util'); const fetch = require('node-fetch'); const path = require('path'); const fs = require('fs').promises; -const { app, BrowserWindow } = require('electron'); -const LocalAIServiceBase = require('./localAIServiceBase'); +const os = require('os'); +const https = require('https'); +const crypto = require('crypto'); +const { app } = require('electron'); const { spawnAsync } = require('../utils/spawnHelper'); const { DOWNLOAD_CHECKSUMS } = require('../config/checksums'); const ollamaModelRepository = require('../repositories/ollamaModel'); -class OllamaService extends LocalAIServiceBase { +const execAsync = promisify(exec); + +class OllamaService extends EventEmitter { constructor() { - super('OllamaService'); + super(); + this.serviceName = 'OllamaService'; this.baseUrl = 'http://localhost:11434'; + + // λ‹¨μˆœν™”λœ μƒνƒœ 관리 + this.installState = { + isInstalled: false, + isInstalling: false, + progress: 0 + }; + + // λ‹¨μˆœν™”λœ μš”μ²­ 관리 (λ³΅μž‘ν•œ 큐 제거) + this.activeRequest = null; + this.requestTimeout = 30000; // 30초 νƒ€μž„μ•„μ›ƒ + + // λͺ¨λΈ μƒνƒœ + this.installedModels = new Map(); + this.modelWarmupStatus = new Map(); + + // 체크포인트 μ‹œμŠ€ν…œ (둀백용) + this.installCheckpoints = []; + + // μ„€μΉ˜ μ§„ν–‰λ₯  관리 + this.installationProgress = new Map(); + + // μ›Œλ° κ΄€λ ¨ (κΈ°μ‘΄ μœ μ§€) this.warmingModels = new Map(); this.warmedModels = new Set(); this.lastWarmUpAttempt = new Map(); - - // Request management system - this.activeRequests = new Map(); - this.requestTimeouts = new Map(); - this.healthStatus = { - lastHealthCheck: 0, - consecutive_failures: 0, - is_circuit_open: false - }; - - // Configuration - this.requestTimeout = 0; // Delete timeout this.warmupTimeout = 120000; // 120s for model warmup - this.healthCheckInterval = 60000; // 1min between health checks - this.circuitBreakerThreshold = 3; - this.circuitBreakerCooldown = 30000; // 30s - // Supported models are determined dynamically from installed models - this.supportedModels = {}; + // μƒνƒœ 동기화 + this._lastState = null; + this._syncInterval = null; + this._lastLoadedModels = []; + this.modelLoadStatus = new Map(); - // Start health monitoring - this._startHealthMonitoring(); + // μ„œλΉ„μŠ€ μ’…λ£Œ μƒνƒœ 좔적 + this.isShuttingDown = false; } - // λͺ¨λ“  μœˆλ„μš°μ— 이벀트 λΈŒλ‘œλ“œμΊμŠ€νŠΈ - _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); - } + + // Base class methods integration + getPlatform() { + return process.platform; + } + + async checkCommand(command) { + try { + const platform = this.getPlatform(); + const checkCmd = platform === 'win32' ? 'where' : 'which'; + const { stdout } = await execAsync(`${checkCmd} ${command}`); + return stdout.trim(); + } catch (error) { + return null; + } + } + + async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) { + for (let i = 0; i < maxAttempts; i++) { + if (await checkFn()) { + console.log(`[${this.serviceName}] Service is ready`); + return true; } - }); + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + throw new Error(`${this.serviceName} service failed to start within timeout`); + } + + getInstallProgress(modelName) { + return this.installationProgress.get(modelName) || 0; + } + + setInstallProgress(modelName, progress) { + this.installationProgress.set(modelName, progress); + } + + clearInstallProgress(modelName) { + this.installationProgress.delete(modelName); } async getStatus() { @@ -80,133 +124,30 @@ class OllamaService extends LocalAIServiceBase { return 'ollama'; } - /** - * Professional request management with AbortController-based cancellation - */ - async _makeRequest(url, options = {}, operationType = 'default') { - const requestId = `${operationType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - - // Circuit breaker check - if (this._isCircuitOpen()) { - throw new Error('Service temporarily unavailable (circuit breaker open)'); + // === λŸ°νƒ€μž„ 관리 (λ‹¨μˆœν™”) === + async makeRequest(endpoint, options = {}) { + // μ„œλΉ„μŠ€ μ’…λ£Œ 쀑이면 μš”μ²­ν•˜μ§€ μ•ŠμŒ + if (this.isShuttingDown) { + throw new Error('Service is shutting down'); } - // Request deduplication for health checks - if (operationType === 'health' && this.activeRequests.has('health')) { - console.log('[OllamaService] Health check already in progress, returning existing promise'); - return this.activeRequests.get('health'); + // λ™μ‹œ μš”μ²­ λ°©μ§€ (λ‹¨μˆœν•œ 잠금) + if (this.activeRequest) { + await this.activeRequest; } - + const controller = new AbortController(); - const timeout = options.timeout || this.requestTimeout; - - // Set up timeout mechanism only if timeout > 0 - let timeoutId = null; - if (timeout > 0) { - timeoutId = setTimeout(() => { - controller.abort(); - this.activeRequests.delete(requestId); - this._recordFailure(); - }, timeout); - - this.requestTimeouts.set(requestId, timeoutId); - } - - const requestPromise = this._executeRequest(url, { + const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout); + + this.activeRequest = fetch(`${this.baseUrl}${endpoint}`, { ...options, signal: controller.signal - }, requestId); - - // Store active request for deduplication and cleanup - this.activeRequests.set(operationType === 'health' ? 'health' : requestId, requestPromise); - - try { - const result = await requestPromise; - this._recordSuccess(); - return result; - } catch (error) { - this._recordFailure(); - if (error.name === 'AbortError') { - throw new Error(`Request timeout after ${timeout}ms`); - } - throw error; - } finally { - if (timeoutId !== null) { - clearTimeout(timeoutId); - this.requestTimeouts.delete(requestId); - } - this.activeRequests.delete(operationType === 'health' ? 'health' : requestId); - } - } - - async _executeRequest(url, options, requestId) { - try { - console.log(`[OllamaService] Executing request ${requestId} to ${url}`); - const response = await fetch(url, options); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return response; - } catch (error) { - console.error(`[OllamaService] Request ${requestId} failed:`, error.message); - throw error; - } - } - - _isCircuitOpen() { - if (!this.healthStatus.is_circuit_open) return false; - - // Check if cooldown period has passed - const now = Date.now(); - if (now - this.healthStatus.lastHealthCheck > this.circuitBreakerCooldown) { - console.log('[OllamaService] Circuit breaker cooldown expired, attempting recovery'); - this.healthStatus.is_circuit_open = false; - this.healthStatus.consecutive_failures = 0; - return false; - } - - return true; - } - - _recordSuccess() { - this.healthStatus.consecutive_failures = 0; - this.healthStatus.is_circuit_open = false; - this.healthStatus.lastHealthCheck = Date.now(); - } - - _recordFailure() { - this.healthStatus.consecutive_failures++; - this.healthStatus.lastHealthCheck = Date.now(); - - if (this.healthStatus.consecutive_failures >= this.circuitBreakerThreshold) { - console.warn(`[OllamaService] Circuit breaker opened after ${this.healthStatus.consecutive_failures} failures`); - this.healthStatus.is_circuit_open = true; - } - } - - _startHealthMonitoring() { - // Passive health monitoring - only when requests are made - console.log('[OllamaService] Health monitoring system initialized'); - } - - /** - * Cleanup all active requests and resources - */ - _cleanup() { - console.log(`[OllamaService] Cleaning up ${this.activeRequests.size} active requests`); - - // Cancel all active requests - for (const [requestId, promise] of this.activeRequests) { - if (this.requestTimeouts.has(requestId)) { - clearTimeout(this.requestTimeouts.get(requestId)); - this.requestTimeouts.delete(requestId); - } - } - - this.activeRequests.clear(); - this.requestTimeouts.clear(); + }).finally(() => { + clearTimeout(timeoutId); + this.activeRequest = null; + }); + + return this.activeRequest; } async isInstalled() { @@ -233,10 +174,11 @@ class OllamaService extends LocalAIServiceBase { async isServiceRunning() { try { - const response = await this._makeRequest(`${this.baseUrl}/api/tags`, { - method: 'GET', - timeout: this.requestTimeout - }, 'health'); + // Use /api/ps to check if service is running + // This is more reliable than /api/tags which may not show models not in memory + const response = await this.makeRequest('/api/ps', { + method: 'GET' + }); return response.ok; } catch (error) { @@ -246,6 +188,9 @@ class OllamaService extends LocalAIServiceBase { } async startService() { + // μ„œλΉ„μŠ€ μ‹œμž‘ μ‹œ μ’…λ£Œ ν”Œλž˜κ·Έ 리셋 + this.isShuttingDown = false; + const platform = this.getPlatform(); try { @@ -281,12 +226,69 @@ class OllamaService extends LocalAIServiceBase { return await this.shutdown(); } - async getInstalledModels() { + // Comprehensive health check using multiple endpoints + async healthCheck() { try { - const response = await this._makeRequest(`${this.baseUrl}/api/tags`, { - method: 'GET', - timeout: this.requestTimeout - }, 'models'); + const checks = { + serviceRunning: false, + apiResponsive: false, + modelsAccessible: false, + memoryStatus: false + }; + + // 1. Basic service check with /api/ps + try { + const psResponse = await this.makeRequest('/api/ps', { method: 'GET' }); + checks.serviceRunning = psResponse.ok; + checks.memoryStatus = psResponse.ok; + } catch (error) { + console.log('[OllamaService] /api/ps check failed:', error.message); + } + + // 2. Check if API is responsive with root endpoint + try { + const rootResponse = await this.makeRequest('/', { method: 'GET' }); + checks.apiResponsive = rootResponse.ok; + } catch (error) { + console.log('[OllamaService] Root endpoint check failed:', error.message); + } + + // 3. Check if models endpoint is accessible + try { + const tagsResponse = await this.makeRequest('/api/tags', { method: 'GET' }); + checks.modelsAccessible = tagsResponse.ok; + } catch (error) { + console.log('[OllamaService] /api/tags check failed:', error.message); + } + + const allHealthy = Object.values(checks).every(v => v === true); + + return { + healthy: allHealthy, + checks, + timestamp: new Date().toISOString() + }; + } catch (error) { + console.error('[OllamaService] Health check failed:', error); + return { + healthy: false, + error: error.message, + timestamp: new Date().toISOString() + }; + } + } + + async getInstalledModels() { + // μ„œλΉ„μŠ€ μ’…λ£Œ 쀑이면 빈 λ°°μ—΄ λ°˜ν™˜ + if (this.isShuttingDown) { + console.log('[OllamaService] Service is shutting down, returning empty models list'); + return []; + } + + try { + const response = await this.makeRequest('/api/tags', { + method: 'GET' + }); const data = await response.json(); return data.models || []; @@ -296,6 +298,59 @@ class OllamaService extends LocalAIServiceBase { } } + // Get models currently loaded in memory using /api/ps + async getLoadedModels() { + // μ„œλΉ„μŠ€ μ’…λ£Œ 쀑이면 빈 λ°°μ—΄ λ°˜ν™˜ + if (this.isShuttingDown) { + console.log('[OllamaService] Service is shutting down, returning empty loaded models list'); + return []; + } + + try { + const response = await this.makeRequest('/api/ps', { + method: 'GET' + }); + + if (!response.ok) { + console.log('[OllamaService] Failed to get loaded models via /api/ps'); + return []; + } + + const data = await response.json(); + // Extract model names from running processes + return (data.models || []).map(m => m.name); + } catch (error) { + console.error('[OllamaService] Error getting loaded models:', error); + return []; + } + } + + // Get detailed memory info for loaded models + async getLoadedModelsWithMemoryInfo() { + try { + const response = await this.makeRequest('/api/ps', { + method: 'GET' + }); + + if (!response.ok) { + return []; + } + + const data = await response.json(); + // Return full model info including memory usage + return data.models || []; + } catch (error) { + console.error('[OllamaService] Error getting loaded models info:', error); + return []; + } + } + + // Check if a specific model is loaded in memory + async isModelLoaded(modelName) { + const loadedModels = await this.getLoadedModels(); + return loadedModels.includes(modelName); + } + async getInstalledModelsList() { try { const { stdout } = await spawnAsync(this.getOllamaCliPath(), ['list']); @@ -360,6 +415,13 @@ class OllamaService extends LocalAIServiceBase { console.log(`[OllamaService] Starting to pull model: ${modelName} via API`); + // Emit progress event - LocalAIManagerκ°€ 처리 + this.emit('install-progress', { + model: modelName, + progress: 0, + status: 'starting' + }); + try { const response = await fetch(`${this.baseUrl}/api/pull`, { method: 'POST', @@ -395,7 +457,8 @@ class OllamaService extends LocalAIServiceBase { if (progress !== null) { this.setInstallProgress(modelName, progress); - this._broadcastToAllWindows('ollama:pull-progress', { + // Emit progress event - LocalAIManagerκ°€ 처리 + this.emit('install-progress', { model: modelName, progress, status: data.status || 'downloading' @@ -406,7 +469,7 @@ class OllamaService extends LocalAIServiceBase { // Handle completion if (data.status === 'success') { console.log(`[OllamaService] Successfully pulled model: ${modelName}`); - this._broadcastToAllWindows('ollama:pull-complete', { model: modelName }); + this.emit('model-pull-complete', { model: modelName }); this.clearInstallProgress(modelName); resolve(); return; @@ -424,7 +487,7 @@ class OllamaService extends LocalAIServiceBase { const data = JSON.parse(buffer); if (data.status === 'success') { console.log(`[OllamaService] Successfully pulled model: ${modelName}`); - this._broadcastToAllWindows('ollama:pull-complete', { model: modelName }); + this.emit('model-pull-complete', { model: modelName }); } } catch (parseError) { console.warn('[OllamaService] Failed to parse final buffer:', buffer); @@ -477,6 +540,163 @@ class OllamaService extends LocalAIServiceBase { + async downloadFile(url, destination, options = {}) { + const { + onProgress = null, + headers = { 'User-Agent': 'Glass-App' }, + timeout = 300000, + modelId = null + } = options; + + return new Promise((resolve, reject) => { + const file = require('fs').createWriteStream(destination); + let downloadedSize = 0; + let totalSize = 0; + + const request = https.get(url, { headers }, (response) => { + if ([301, 302, 307, 308].includes(response.statusCode)) { + file.close(); + require('fs').unlink(destination, () => {}); + + if (!response.headers.location) { + reject(new Error('Redirect without location header')); + return; + } + + console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`); + this.downloadFile(response.headers.location, destination, options) + .then(resolve) + .catch(reject); + return; + } + + if (response.statusCode !== 200) { + file.close(); + require('fs').unlink(destination, () => {}); + reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)); + return; + } + + totalSize = parseInt(response.headers['content-length'], 10) || 0; + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + + if (totalSize > 0) { + const progress = Math.round((downloadedSize / totalSize) * 100); + + if (onProgress) { + onProgress(progress, downloadedSize, totalSize); + } + } + }); + + response.pipe(file); + + file.on('finish', () => { + file.close(() => { + resolve({ success: true, size: downloadedSize }); + }); + }); + }); + + request.on('timeout', () => { + request.destroy(); + file.close(); + require('fs').unlink(destination, () => {}); + reject(new Error('Download timeout')); + }); + + request.on('error', (err) => { + file.close(); + require('fs').unlink(destination, () => {}); + this.emit('download-error', { url, error: err, modelId }); + reject(err); + }); + + request.setTimeout(timeout); + + file.on('error', (err) => { + require('fs').unlink(destination, () => {}); + reject(err); + }); + }); + } + + async downloadWithRetry(url, destination, options = {}) { + const { + maxRetries = 3, + retryDelay = 1000, + expectedChecksum = null, + modelId = null, + ...downloadOptions + } = options; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await this.downloadFile(url, destination, { + ...downloadOptions, + modelId + }); + + if (expectedChecksum) { + const isValid = await this.verifyChecksum(destination, expectedChecksum); + if (!isValid) { + require('fs').unlinkSync(destination); + throw new Error('Checksum verification failed'); + } + console.log(`[${this.serviceName}] Checksum verified successfully`); + } + + return result; + } catch (error) { + if (attempt === maxRetries) { + throw error; + } + + console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, retryDelay * attempt)); + } + } + } + + async verifyChecksum(filePath, expectedChecksum) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = require('fs').createReadStream(filePath); + + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => { + const fileChecksum = hash.digest('hex'); + console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`); + console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`); + resolve(fileChecksum === expectedChecksum); + }); + stream.on('error', reject); + }); + } + + async autoInstall(onProgress) { + const platform = this.getPlatform(); + console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`); + + try { + switch(platform) { + case 'darwin': + return await this.installMacOS(onProgress); + case 'win32': + return await this.installWindows(onProgress); + case 'linux': + return await this.installLinux(); + default: + throw new Error(`Unsupported platform: ${platform}`); + } + } catch (error) { + console.error(`[${this.serviceName}] Auto-installation failed:`, error); + throw error; + } + } + async installMacOS(onProgress) { console.log('[OllamaService] Installing Ollama on macOS using DMG...'); @@ -486,6 +706,9 @@ class OllamaService extends LocalAIServiceBase { const dmgPath = path.join(tempDir, 'Ollama.dmg'); const mountPoint = path.join(tempDir, 'OllamaMount'); + // 체크포인트 μ €μž₯ + await this.saveCheckpoint('pre-install'); + console.log('[OllamaService] Step 1: Downloading Ollama DMG...'); onProgress?.({ stage: 'downloading', message: 'Downloading Ollama installer...', progress: 0 }); const checksumInfo = DOWNLOAD_CHECKSUMS.ollama.dmg; @@ -496,6 +719,8 @@ class OllamaService extends LocalAIServiceBase { } }); + await this.saveCheckpoint('post-download'); + console.log('[OllamaService] Step 2: Mounting DMG...'); onProgress?.({ stage: 'mounting', message: 'Mounting disk image...', progress: 0 }); await fs.mkdir(mountPoint, { recursive: true }); @@ -507,6 +732,8 @@ class OllamaService extends LocalAIServiceBase { await spawnAsync('cp', ['-R', `${mountPoint}/Ollama.app`, '/Applications/']); onProgress?.({ stage: 'installing', message: 'Application installed.', progress: 100 }); + await this.saveCheckpoint('post-install'); + console.log('[OllamaService] Step 4: Setting up CLI path...'); onProgress?.({ stage: 'linking', message: 'Creating command-line shortcut...', progress: 0 }); try { @@ -533,6 +760,8 @@ class OllamaService extends LocalAIServiceBase { return true; } catch (error) { console.error('[OllamaService] macOS installation failed:', error); + // μ„€μΉ˜ μ‹€νŒ¨ μ‹œ 정리 + await fs.unlink(dmgPath).catch(() => {}); throw new Error(`Failed to install Ollama on macOS: ${error.message}`); } } @@ -586,7 +815,135 @@ class OllamaService extends LocalAIServiceBase { throw new Error('Manual installation required on Linux. Please visit https://ollama.com/download/linux'); } + // === 체크포인트 & λ‘€λ°± μ‹œμŠ€ν…œ === + async saveCheckpoint(name) { + this.installCheckpoints.push({ + name, + timestamp: Date.now(), + state: { ...this.installState } + }); + } + async rollbackToLastCheckpoint() { + const checkpoint = this.installCheckpoints.pop(); + if (checkpoint) { + console.log(`[OllamaService] Rolling back to checkpoint: ${checkpoint.name}`); + // ν”Œλž«νΌλ³„ λ‘€λ°± 둜직 μ‹€ν–‰ + await this._executeRollback(checkpoint); + } + } + + async _executeRollback(checkpoint) { + const platform = this.getPlatform(); + + if (platform === 'darwin' && checkpoint.name === 'post-install') { + // macOS λ‘€λ°± + await fs.rm('/Applications/Ollama.app', { recursive: true, force: true }).catch(() => {}); + } else if (platform === 'win32') { + // Windows λ‘€λ°± (λ ˆμ§€μŠ€νŠΈλ¦¬ λ“±) + // TODO: Windows λ‘€λ°± κ΅¬ν˜„ + } + + this.installState = checkpoint.state; + } + + // === μƒνƒœ 동기화 (λ‚΄λΆ€ 처리) === + async syncState() { + // μ„œλΉ„μŠ€ μ’…λ£Œ 쀑이면 μŠ€ν‚΅ + if (this.isShuttingDown) { + console.log('[OllamaService] Service is shutting down, skipping state sync'); + return this.installState; + } + + try { + const isInstalled = await this.isInstalled(); + const isRunning = await this.isServiceRunning(); + const models = isRunning && !this.isShuttingDown ? await this.getInstalledModels() : []; + const loadedModels = isRunning && !this.isShuttingDown ? await this.getLoadedModels() : []; + + // μƒνƒœ μ—…λ°μ΄νŠΈ + this.installState.isInstalled = isInstalled; + this.installState.isRunning = isRunning; + this.installState.lastSync = Date.now(); + + // λ©”λͺ¨λ¦¬ λ‘œλ“œ μƒνƒœ 좔적 + const previousLoadedModels = this._lastLoadedModels || []; + const loadedChanged = loadedModels.length !== previousLoadedModels.length || + !loadedModels.every(m => previousLoadedModels.includes(m)); + + if (loadedChanged) { + console.log(`[OllamaService] Loaded models changed: ${loadedModels.join(', ')}`); + this._lastLoadedModels = loadedModels; + + // λ©”λͺ¨λ¦¬μ—μ„œ μ–Έλ‘œλ“œλœ λͺ¨λΈμ˜ warmed μƒνƒœ 제거 + for (const modelName of this.warmedModels) { + if (!loadedModels.includes(modelName)) { + this.warmedModels.delete(modelName); + console.log(`[OllamaService] Model ${modelName} unloaded from memory, removing warmed state`); + } + } + } + + // λͺ¨λΈ μƒνƒœ DB μ—…λ°μ΄νŠΈ + if (isRunning && models.length > 0) { + for (const model of models) { + try { + const isLoaded = loadedModels.includes(model.name); + // DBμ—λŠ” installed μƒνƒœλ§Œ μ €μž₯, loaded μƒνƒœλŠ” λ©”λͺ¨λ¦¬μ—μ„œ 관리 + await ollamaModelRepository.updateInstallStatus(model.name, true, false); + + // λ‘œλ“œ μƒνƒœλ₯Ό μΈμŠ€ν„΄μŠ€ λ³€μˆ˜μ— μ €μž₯ + if (!this.modelLoadStatus) { + this.modelLoadStatus = new Map(); + } + this.modelLoadStatus.set(model.name, isLoaded); + } catch (dbError) { + console.warn(`[OllamaService] Failed to update DB for model ${model.name}:`, dbError); + } + } + } + + // UI μ•Œλ¦Ό (μƒνƒœ λ³€κ²½ μ‹œλ§Œ) + if (this._lastState?.isRunning !== isRunning || + this._lastState?.isInstalled !== isInstalled || + loadedChanged) { + // Emit state change event - LocalAIManagerκ°€ 처리 + this.emit('state-changed', { + installed: isInstalled, + running: isRunning, + models: models.length, + loadedModels: loadedModels + }); + } + + this._lastState = { isInstalled, isRunning, modelsCount: models.length }; + return { isInstalled, isRunning, models }; + + } catch (error) { + console.error('[OllamaService] State sync failed:', error); + return { + isInstalled: this.installState.isInstalled || false, + isRunning: false, + models: [] + }; + } + } + + // 주기적 동기화 μ‹œμž‘ + startPeriodicSync() { + if (this._syncInterval) return; + + this._syncInterval = setInterval(() => { + this.syncState(); + }, 30000); // 30μ΄ˆλ§ˆλ‹€ + } + + stopPeriodicSync() { + if (this._syncInterval) { + clearInterval(this._syncInterval); + this._syncInterval = null; + } + } async warmUpModel(modelName, forceRefresh = false) { if (!modelName?.trim()) { @@ -638,7 +995,7 @@ class OllamaService extends LocalAIServiceBase { console.log(`[OllamaService] Starting warm-up for model: ${modelName}`); try { - const response = await this._makeRequest(`${this.baseUrl}/api/chat`, { + const response = await this.makeRequest('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -651,9 +1008,8 @@ class OllamaService extends LocalAIServiceBase { num_predict: 1, // Minimal response temperature: 0 } - }), - timeout: this.warmupTimeout - }, `warmup_${modelName}`); + }) + }); return true; } catch (error) { @@ -670,7 +1026,7 @@ class OllamaService extends LocalAIServiceBase { await ollamaModelRepository.updateInstallStatus(modelName, true, false); // Retry warm-up after installation - const retryResponse = await this._makeRequest(`${this.baseUrl}/api/chat`, { + const retryResponse = await this.makeRequest('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -683,9 +1039,8 @@ class OllamaService extends LocalAIServiceBase { num_predict: 1, temperature: 0 } - }), - timeout: this.warmupTimeout - }, `warmup_retry_${modelName}`); + }) + }); console.log(`[OllamaService] Successfully warmed up model ${modelName} after installation`); return true; @@ -731,7 +1086,14 @@ class OllamaService extends LocalAIServiceBase { // μ„€μΉ˜ μ—¬λΆ€ 체크 제거 - _performWarmUpμ—μ„œ μžλ™μœΌλ‘œ μ„€μΉ˜ 처리 console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId} (will auto-install if needed)`); - return await this.warmUpModel(llmModelId); + const result = await this.warmUpModel(llmModelId); + + // 성곡 μ‹œ LocalAIManager에 μ•Œλ¦Ό + if (result) { + this.emit('model-warmed-up', { model: llmModelId }); + } + + return result; } catch (error) { console.error('[OllamaService] Auto warm-up failed:', error); @@ -746,10 +1108,13 @@ class OllamaService extends LocalAIServiceBase { console.log('[OllamaService] Warm-up cache cleared'); } - getWarmUpStatus() { + async getWarmUpStatus() { + const loadedModels = await this.getLoadedModels(); + return { warmedModels: Array.from(this.warmedModels), warmingModels: Array.from(this.warmingModels.keys()), + loadedModels: loadedModels, // Models actually loaded in memory lastAttempts: Object.fromEntries(this.lastWarmUpAttempt) }; } @@ -757,6 +1122,9 @@ class OllamaService extends LocalAIServiceBase { async shutdown(force = false) { console.log(`[OllamaService] Shutdown initiated (force: ${force})`); + // μ’…λ£Œ 쀑 ν”Œλž˜κ·Έ μ„€μ • + this.isShuttingDown = true; + if (!force && this.warmingModels.size > 0) { const warmingList = Array.from(this.warmingModels.keys()); console.log(`[OllamaService] Waiting for ${warmingList.length} models to finish warming: ${warmingList.join(', ')}`); @@ -773,39 +1141,81 @@ class OllamaService extends LocalAIServiceBase { } // Clean up all resources - this._cleanup(); this._clearWarmUpCache(); + this.stopPeriodicSync(); - return super.shutdown(force); + // ν”„λ‘œμ„ΈμŠ€ μ’…λ£Œ + const isRunning = await this.isServiceRunning(); + if (!isRunning) { + console.log('[OllamaService] Service not running, nothing to shutdown'); + return true; + } + + const platform = this.getPlatform(); + + try { + switch(platform) { + case 'darwin': + return await this.shutdownMacOS(force); + case 'win32': + return await this.shutdownWindows(force); + case 'linux': + return await this.shutdownLinux(force); + default: + console.warn(`[OllamaService] Unsupported platform for shutdown: ${platform}`); + return false; + } + } catch (error) { + console.error(`[OllamaService] Error during shutdown:`, error); + return false; + } } async shutdownMacOS(force) { try { - // Try to quit Ollama.app gracefully - await spawnAsync('osascript', ['-e', 'tell application "Ollama" to quit']); - console.log('[OllamaService] Ollama.app quit successfully'); + // 1. First, try to kill ollama server process + console.log('[OllamaService] Killing ollama server process...'); + try { + await spawnAsync('pkill', ['-f', 'ollama serve']); + } catch (e) { + // Process might not be running + } - // Wait a moment for graceful shutdown + // 2. Then quit the Ollama.app + console.log('[OllamaService] Quitting Ollama.app...'); + try { + await spawnAsync('osascript', ['-e', 'tell application "Ollama" to quit']); + } catch (e) { + console.log('[OllamaService] Ollama.app might not be running'); + } + + // 3. Wait a moment for shutdown await new Promise(resolve => setTimeout(resolve, 2000)); - // Check if still running - const stillRunning = await this.isServiceRunning(); - if (stillRunning) { - console.log('[OllamaService] Ollama still running, forcing shutdown'); - // Force kill if necessary - await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]); + // 4. Force kill any remaining ollama processes + if (force || await this.isServiceRunning()) { + console.log('[OllamaService] Force killing any remaining ollama processes...'); + try { + // Kill all ollama processes + await spawnAsync('pkill', ['-9', '-f', 'ollama']); + } catch (e) { + // Ignore errors - process might not exist + } } - return true; - } catch (error) { - console.log('[OllamaService] Graceful quit failed, trying force kill'); - try { - await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]); - return true; - } catch (killError) { - console.error('[OllamaService] Failed to force kill Ollama:', killError); + // 5. Final check + await new Promise(resolve => setTimeout(resolve, 1000)); + const stillRunning = await this.isServiceRunning(); + if (stillRunning) { + console.warn('[OllamaService] Warning: Ollama may still be running'); return false; } + + console.log('[OllamaService] Ollama shutdown complete'); + return true; + } catch (error) { + console.error('[OllamaService] Shutdown error:', error); + return false; } } @@ -845,8 +1255,15 @@ class OllamaService extends LocalAIServiceBase { // Get all installed models directly from Ollama const installedModels = await this.getInstalledModels(); + // Get loaded models from memory + const loadedModels = await this.getLoadedModels(); + const models = []; for (const model of installedModels) { + const isWarmingUp = this.warmingModels.has(model.name); + const isWarmedUp = this.warmedModels.has(model.name); + const isLoaded = loadedModels.includes(model.name); + models.push({ name: model.name, displayName: model.name, // Use model name as display name @@ -854,7 +1271,11 @@ class OllamaService extends LocalAIServiceBase { description: `Ollama model: ${model.name}`, installed: true, installing: this.installationProgress.has(model.name), - progress: this.getInstallProgress(model.name) + progress: this.getInstallProgress(model.name), + warmedUp: isWarmedUp, + isWarmingUp, + isLoaded, // Actually loaded in memory + status: isWarmingUp ? 'warming' : (isLoaded ? 'loaded' : (isWarmedUp ? 'ready' : 'cold')) }); } @@ -899,21 +1320,38 @@ class OllamaService extends LocalAIServiceBase { async handleInstall() { try { const onProgress = (data) => { - this._broadcastToAllWindows('ollama:install-progress', data); + // Emit progress event - LocalAIManagerκ°€ 처리 + this.emit('install-progress', data); }; await this.autoInstall(onProgress); + + // μ„€μΉ˜ 검증 + onProgress({ stage: 'verifying', message: 'Verifying installation...', progress: 0 }); + const verifyResult = await this.verifyInstallation(); + if (!verifyResult.success) { + throw new Error(`Installation verification failed: ${verifyResult.error}`); + } + onProgress({ stage: 'verifying', message: 'Installation verified.', progress: 100 }); if (!await this.isServiceRunning()) { onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 }); await this.startService(); onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 }); } - this._broadcastToAllWindows('ollama:install-complete', { success: true }); + + this.installState.isInstalled = true; + // Emit completion event - LocalAIManagerκ°€ 처리 + this.emit('installation-complete'); return { success: true }; } catch (error) { console.error('[OllamaService] Failed to install:', error); - this._broadcastToAllWindows('ollama:install-complete', { success: false, error: error.message }); + await this.rollbackToLastCheckpoint(); + // Emit error event - LocalAIManagerκ°€ 처리 + this.emit('error', { + errorType: 'installation-failed', + error: error.message + }); return { success: false, error: error.message }; } } @@ -981,7 +1419,12 @@ class OllamaService extends LocalAIServiceBase { } catch (error) { console.error('[OllamaService] Failed to pull model:', error); await ollamaModelRepository.updateInstallStatus(modelName, false, false); - this._broadcastToAllWindows('ollama:pull-error', { model: modelName, error: error.message }); + // Emit error event - LocalAIManagerκ°€ 처리 + this.emit('error', { + errorType: 'model-pull-failed', + model: modelName, + error: error.message + }); return { success: false, error: error.message }; } } @@ -1018,7 +1461,7 @@ class OllamaService extends LocalAIServiceBase { async handleGetWarmUpStatus() { try { - const status = this.getWarmUpStatus(); + const status = await this.getWarmUpStatus(); return { success: true, status }; } catch (error) { console.error('[OllamaService] Failed to get warm-up status:', error); @@ -1030,12 +1473,59 @@ class OllamaService extends LocalAIServiceBase { try { console.log(`[OllamaService] Manual shutdown requested (force: ${force})`); const success = await this.shutdown(force); + + // μ’…λ£Œ ν›„ μƒνƒœ μ—…λ°μ΄νŠΈ 및 ν”Œλž˜κ·Έ 리셋 + if (success) { + // μ’…λ£Œ μ™„λ£Œ ν›„ ν”Œλž˜κ·Έ 리셋 + this.isShuttingDown = false; + await this.syncState(); + } + return { success }; } catch (error) { console.error('[OllamaService] Failed to shutdown Ollama:', error); return { success: false, error: error.message }; } } + + // μ„€μΉ˜ 검증 + async verifyInstallation() { + try { + console.log('[OllamaService] Verifying installation...'); + + // 1. λ°”μ΄λ„ˆλ¦¬ 확인 + const isInstalled = await this.isInstalled(); + if (!isInstalled) { + return { success: false, error: 'Ollama binary not found' }; + } + + // 2. CLI λͺ…λ Ή ν…ŒμŠ€νŠΈ + try { + const { stdout } = await spawnAsync(this.getOllamaCliPath(), ['--version']); + console.log('[OllamaService] Ollama version:', stdout.trim()); + } catch (error) { + return { success: false, error: 'Ollama CLI not responding' }; + } + + // 3. μ„œλΉ„μŠ€ μ‹œμž‘ κ°€λŠ₯ μ—¬λΆ€ 확인 + const platform = this.getPlatform(); + if (platform === 'darwin') { + // macOS: μ•± λ²ˆλ“€ 확인 + try { + await fs.access('/Applications/Ollama.app/Contents/MacOS/Ollama'); + } catch (error) { + return { success: false, error: 'Ollama.app executable not found' }; + } + } + + console.log('[OllamaService] Installation verified successfully'); + return { success: true }; + + } catch (error) { + console.error('[OllamaService] Verification failed:', error); + return { success: false, error: error.message }; + } + } } // Export singleton instance diff --git a/src/features/common/services/whisperService.js b/src/features/common/services/whisperService.js index 4f44cd1..e676913 100644 --- a/src/features/common/services/whisperService.js +++ b/src/features/common/services/whisperService.js @@ -1,21 +1,40 @@ -const { spawn } = require('child_process'); +const { EventEmitter } = require('events'); +const { spawn, exec } = require('child_process'); +const { promisify } = require('util'); const path = require('path'); const fs = require('fs'); const os = require('os'); -const { BrowserWindow } = require('electron'); -const LocalAIServiceBase = require('./localAIServiceBase'); +const https = require('https'); +const crypto = require('crypto'); const { spawnAsync } = require('../utils/spawnHelper'); const { DOWNLOAD_CHECKSUMS } = require('../config/checksums'); +const execAsync = promisify(exec); + const fsPromises = fs.promises; -class WhisperService extends LocalAIServiceBase { +class WhisperService extends EventEmitter { constructor() { - super('WhisperService'); - this.isInitialized = false; + super(); + this.serviceName = 'WhisperService'; + + // 경둜 및 디렉토리 this.whisperPath = null; this.modelsDir = null; this.tempDir = null; + + // μ„Έμ…˜ 관리 (μ„Έμ…˜ ν’€ λ‚΄μž₯) + this.sessionPool = []; + this.activeSessions = new Map(); + this.maxSessions = 3; + + // μ„€μΉ˜ μƒνƒœ + this.installState = { + isInstalled: false, + isInitialized: false + }; + + // μ‚¬μš© κ°€λŠ₯ν•œ λͺ¨λΈ this.availableModels = { 'whisper-tiny': { name: 'Tiny', @@ -40,21 +59,222 @@ class WhisperService extends LocalAIServiceBase { }; } - // λͺ¨λ“  μœˆλ„μš°μ— 이벀트 λΈŒλ‘œλ“œμΊμŠ€νŠΈ - _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); - } + + // Base class methods integration + getPlatform() { + return process.platform; + } + + async checkCommand(command) { + try { + const platform = this.getPlatform(); + const checkCmd = platform === 'win32' ? 'where' : 'which'; + const { stdout } = await execAsync(`${checkCmd} ${command}`); + return stdout.trim(); + } catch (error) { + return null; + } + } + + async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) { + for (let i = 0; i < maxAttempts; i++) { + if (await checkFn()) { + console.log(`[${this.serviceName}] Service is ready`); + return true; } + await new Promise(resolve => setTimeout(resolve, delayMs)); + } + throw new Error(`${this.serviceName} service failed to start within timeout`); + } + + async downloadFile(url, destination, options = {}) { + const { + onProgress = null, + headers = { 'User-Agent': 'Glass-App' }, + timeout = 300000, + modelId = null + } = options; + + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(destination); + let downloadedSize = 0; + let totalSize = 0; + + const request = https.get(url, { headers }, (response) => { + if ([301, 302, 307, 308].includes(response.statusCode)) { + file.close(); + fs.unlink(destination, () => {}); + + if (!response.headers.location) { + reject(new Error('Redirect without location header')); + return; + } + + console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`); + this.downloadFile(response.headers.location, destination, options) + .then(resolve) + .catch(reject); + return; + } + + if (response.statusCode !== 200) { + file.close(); + fs.unlink(destination, () => {}); + reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`)); + return; + } + + totalSize = parseInt(response.headers['content-length'], 10) || 0; + + response.on('data', (chunk) => { + downloadedSize += chunk.length; + + if (totalSize > 0) { + const progress = Math.round((downloadedSize / totalSize) * 100); + + if (onProgress) { + onProgress(progress, downloadedSize, totalSize); + } + } + }); + + response.pipe(file); + + file.on('finish', () => { + file.close(() => { + resolve({ success: true, size: downloadedSize }); + }); + }); + }); + + request.on('timeout', () => { + request.destroy(); + file.close(); + fs.unlink(destination, () => {}); + reject(new Error('Download timeout')); + }); + + request.on('error', (err) => { + file.close(); + fs.unlink(destination, () => {}); + this.emit('download-error', { url, error: err, modelId }); + reject(err); + }); + + request.setTimeout(timeout); + + file.on('error', (err) => { + fs.unlink(destination, () => {}); + reject(err); + }); }); } + async downloadWithRetry(url, destination, options = {}) { + const { + maxRetries = 3, + retryDelay = 1000, + expectedChecksum = null, + modelId = null, + ...downloadOptions + } = options; + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + const result = await this.downloadFile(url, destination, { + ...downloadOptions, + modelId + }); + + if (expectedChecksum) { + const isValid = await this.verifyChecksum(destination, expectedChecksum); + if (!isValid) { + fs.unlinkSync(destination); + throw new Error('Checksum verification failed'); + } + console.log(`[${this.serviceName}] Checksum verified successfully`); + } + + return result; + } catch (error) { + if (attempt === maxRetries) { + throw error; + } + + console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`); + await new Promise(resolve => setTimeout(resolve, retryDelay * attempt)); + } + } + } + + async verifyChecksum(filePath, expectedChecksum) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('sha256'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => hash.update(data)); + stream.on('end', () => { + const fileChecksum = hash.digest('hex'); + console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`); + console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`); + resolve(fileChecksum === expectedChecksum); + }); + stream.on('error', reject); + }); + } + + async autoInstall(onProgress) { + const platform = this.getPlatform(); + console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`); + + try { + switch(platform) { + case 'darwin': + return await this.installMacOS(onProgress); + case 'win32': + return await this.installWindows(onProgress); + case 'linux': + return await this.installLinux(); + default: + throw new Error(`Unsupported platform: ${platform}`); + } + } catch (error) { + console.error(`[${this.serviceName}] Auto-installation failed:`, error); + throw error; + } + } + + async shutdown(force = false) { + console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`); + + const isRunning = await this.isServiceRunning(); + if (!isRunning) { + console.log(`[${this.serviceName}] Service not running, nothing to shutdown`); + return true; + } + + const platform = this.getPlatform(); + + try { + switch(platform) { + case 'darwin': + return await this.shutdownMacOS(force); + case 'win32': + return await this.shutdownWindows(force); + case 'linux': + return await this.shutdownLinux(force); + default: + console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`); + return false; + } + } catch (error) { + console.error(`[${this.serviceName}] Error during shutdown:`, error); + return false; + } + } + async initialize() { - if (this.isInitialized) return; + if (this.installState.isInitialized) return; try { const homeDir = os.homedir(); @@ -71,10 +291,15 @@ class WhisperService extends LocalAIServiceBase { await this.ensureDirectories(); await this.ensureWhisperBinary(); - this.isInitialized = true; + this.installState.isInitialized = true; console.log('[WhisperService] Initialized successfully'); } catch (error) { console.error('[WhisperService] Initialization failed:', error); + // Emit error event - LocalAIManagerκ°€ 처리 + this.emit('error', { + errorType: 'initialization-failed', + error: error.message + }); throw error; } } @@ -85,6 +310,56 @@ class WhisperService extends LocalAIServiceBase { await fsPromises.mkdir(path.dirname(this.whisperPath), { recursive: true }); } + // local stt session + async getSession(config) { + // check available session + const availableSession = this.sessionPool.find(s => !s.inUse); + if (availableSession) { + availableSession.inUse = true; + await availableSession.reconfigure(config); + return availableSession; + } + + // create new session + if (this.activeSessions.size >= this.maxSessions) { + throw new Error('Maximum session limit reached'); + } + + const session = new WhisperSession(config, this); + await session.initialize(); + this.activeSessions.set(session.id, session); + + return session; + } + + async releaseSession(sessionId) { + const session = this.activeSessions.get(sessionId); + if (session) { + await session.cleanup(); + session.inUse = false; + + // add to session pool + if (this.sessionPool.length < 2) { + this.sessionPool.push(session); + } else { + // remove session + await session.destroy(); + this.activeSessions.delete(sessionId); + } + } + } + + //cleanup + async cleanup() { + // cleanup all sessions + for (const session of this.activeSessions.values()) { + await session.destroy(); + } + + this.activeSessions.clear(); + this.sessionPool = []; + } + async ensureWhisperBinary() { const whisperCliPath = await this.checkCommand('whisper-cli'); if (whisperCliPath) { @@ -113,6 +388,11 @@ class WhisperService extends LocalAIServiceBase { console.log('[WhisperService] Whisper not found, trying Homebrew installation...'); try { await this.installViaHomebrew(); + // verify installation + const verified = await this.verifyInstallation(); + if (!verified.success) { + throw new Error(verified.error); + } return; } catch (error) { console.log('[WhisperService] Homebrew installation failed:', error.message); @@ -120,6 +400,12 @@ class WhisperService extends LocalAIServiceBase { } await this.autoInstall(); + + // verify installation + const verified = await this.verifyInstallation(); + if (!verified.success) { + throw new Error(`Whisper installation verification failed: ${verified.error}`); + } } async installViaHomebrew() { @@ -146,7 +432,7 @@ class WhisperService extends LocalAIServiceBase { async ensureModelAvailable(modelId) { - if (!this.isInitialized) { + if (!this.installState.isInitialized) { console.log('[WhisperService] Service not initialized, initializing now...'); await this.initialize(); } @@ -171,25 +457,33 @@ class WhisperService extends LocalAIServiceBase { const modelPath = await this.getModelPath(modelId); const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId]; - this._broadcastToAllWindows('whisper:download-progress', { modelId, progress: 0 }); + // Emit progress event - LocalAIManagerκ°€ 처리 + this.emit('install-progress', { + model: modelId, + progress: 0 + }); await this.downloadWithRetry(modelInfo.url, modelPath, { expectedChecksum: checksumInfo?.sha256, - modelId, // modelIdλ₯Ό μ „λ‹¬ν•˜μ—¬ LocalAIServiceBaseμ—μ„œ 이벀트 λ°œμƒ μ‹œ μ‚¬μš© + modelId, // pass modelId to LocalAIServiceBase for event handling onProgress: (progress) => { - this._broadcastToAllWindows('whisper:download-progress', { modelId, progress }); + // Emit progress event - LocalAIManagerκ°€ 처리 + this.emit('install-progress', { + model: modelId, + progress + }); } }); console.log(`[WhisperService] Model ${modelId} downloaded successfully`); - this._broadcastToAllWindows('whisper:download-complete', { modelId }); + this.emit('model-download-complete', { modelId }); } async handleDownloadModel(modelId) { try { console.log(`[WhisperService] Handling download for model: ${modelId}`); - if (!this.isInitialized) { + if (!this.installState.isInitialized) { await this.initialize(); } @@ -204,7 +498,7 @@ class WhisperService extends LocalAIServiceBase { async handleGetInstalledModels() { try { - if (!this.isInitialized) { + if (!this.installState.isInitialized) { await this.initialize(); } const models = await this.getInstalledModels(); @@ -216,7 +510,7 @@ class WhisperService extends LocalAIServiceBase { } async getModelPath(modelId) { - if (!this.isInitialized || !this.modelsDir) { + if (!this.installState.isInitialized || !this.modelsDir) { throw new Error('WhisperService is not initialized. Call initialize() first.'); } return path.join(this.modelsDir, `${modelId}.bin`); @@ -241,7 +535,7 @@ class WhisperService extends LocalAIServiceBase { createWavHeader(dataSize) { const header = Buffer.alloc(44); - const sampleRate = 24000; + const sampleRate = 16000; const numChannels = 1; const bitsPerSample = 16; @@ -290,7 +584,7 @@ class WhisperService extends LocalAIServiceBase { } async getInstalledModels() { - if (!this.isInitialized) { + if (!this.installState.isInitialized) { console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...'); await this.initialize(); } @@ -319,11 +613,11 @@ class WhisperService extends LocalAIServiceBase { } async isServiceRunning() { - return this.isInitialized; + return this.installState.isInitialized; } async startService() { - if (!this.isInitialized) { + if (!this.installState.isInitialized) { await this.initialize(); } return true; @@ -493,6 +787,92 @@ class WhisperService extends LocalAIServiceBase { } } +// WhisperSession class +class WhisperSession { + constructor(config, service) { + this.id = `session_${Date.now()}_${Math.random()}`; + this.config = config; + this.service = service; + this.process = null; + this.inUse = true; + this.audioBuffer = Buffer.alloc(0); + } + + async initialize() { + await this.service.ensureModelAvailable(this.config.model); + this.startProcessingLoop(); + } + + async reconfigure(config) { + this.config = config; + await this.service.ensureModelAvailable(this.config.model); + } + + startProcessingLoop() { + // TODO: μ‹€μ œ 처리 루프 κ΅¬ν˜„ + } + + async cleanup() { + // μž„μ‹œ 파일 정리 + await this.cleanupTempFiles(); + } + + async cleanupTempFiles() { + // TODO: μž„μ‹œ 파일 정리 κ΅¬ν˜„ + } + + async destroy() { + if (this.process) { + this.process.kill(); + } + // μž„μ‹œ 파일 정리 + await this.cleanupTempFiles(); + } +} + +// verify installation +WhisperService.prototype.verifyInstallation = async function() { + try { + console.log('[WhisperService] Verifying installation...'); + + // 1. check binary + if (!this.whisperPath) { + return { success: false, error: 'Whisper binary path not set' }; + } + + try { + await fsPromises.access(this.whisperPath, fs.constants.X_OK); + } catch (error) { + return { success: false, error: 'Whisper binary not executable' }; + } + + // 2. check version + try { + const { stdout } = await spawnAsync(this.whisperPath, ['--help']); + if (!stdout.includes('whisper')) { + return { success: false, error: 'Invalid whisper binary' }; + } + } catch (error) { + return { success: false, error: 'Whisper binary not responding' }; + } + + // 3. check directories + try { + await fsPromises.access(this.modelsDir, fs.constants.W_OK); + await fsPromises.access(this.tempDir, fs.constants.W_OK); + } catch (error) { + return { success: false, error: 'Required directories not accessible' }; + } + + console.log('[WhisperService] Installation verified successfully'); + return { success: true }; + + } catch (error) { + console.error('[WhisperService] Verification failed:', error); + return { success: false, error: error.message }; + } +}; + // Export singleton instance const whisperService = new WhisperService(); module.exports = whisperService; \ No newline at end of file diff --git a/src/features/listen/listenService.js b/src/features/listen/listenService.js index d879d60..33b7cfc 100644 --- a/src/features/listen/listenService.js +++ b/src/features/listen/listenService.js @@ -110,13 +110,17 @@ class ListenService { console.log('[ListenService] changeSession to "Listen"'); internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true }); await this.initializeSession(); - listenWindow.webContents.send('session-state-changed', { isActive: true }); + if (listenWindow && !listenWindow.isDestroyed()) { + listenWindow.webContents.send('session-state-changed', { isActive: true }); + } break; case 'Stop': console.log('[ListenService] changeSession to "Stop"'); await this.closeSession(); - listenWindow.webContents.send('session-state-changed', { isActive: false }); + if (listenWindow && !listenWindow.isDestroyed()) { + listenWindow.webContents.send('session-state-changed', { isActive: false }); + } break; case 'Done': diff --git a/src/features/settings/settingsService.js b/src/features/settings/settingsService.js index da68a3a..27fb65d 100644 --- a/src/features/settings/settingsService.js +++ b/src/features/settings/settingsService.js @@ -6,8 +6,7 @@ const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window // New imports for common services const modelStateService = require('../common/services/modelStateService'); -const ollamaService = require('../common/services/ollamaService'); -const whisperService = require('../common/services/whisperService'); +const localAIManager = require('../common/services/localAIManager'); const store = new Store({ name: 'pickle-glass-settings', @@ -58,17 +57,21 @@ async function setSelectedModel(type, modelId) { return { success }; } -// Ollama facade functions +// LocalAI facade functions async function getOllamaStatus() { - return ollamaService.getStatus(); + return localAIManager.getServiceStatus('ollama'); } async function ensureOllamaReady() { - return ollamaService.ensureReady(); + const status = await localAIManager.getServiceStatus('ollama'); + if (!status.installed || !status.running) { + await localAIManager.startService('ollama'); + } + return { success: true }; } async function shutdownOllama() { - return ollamaService.shutdown(false); // false for graceful shutdown + return localAIManager.stopService('ollama'); } diff --git a/src/preload.js b/src/preload.js index 04b1046..6013134 100644 --- a/src/preload.js +++ b/src/preload.js @@ -31,11 +31,20 @@ contextBridge.exposeInMainWorld('api', { apiKeyHeader: { // Model & Provider Management getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'), - getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'), + // LocalAI 톡합 API + getLocalAIStatus: (service) => ipcRenderer.invoke('localai:get-status', service), + installLocalAI: (service, options) => ipcRenderer.invoke('localai:install', { service, options }), + startLocalAIService: (service) => ipcRenderer.invoke('localai:start-service', service), + stopLocalAIService: (service) => ipcRenderer.invoke('localai:stop-service', service), + installLocalAIModel: (service, modelId, options) => ipcRenderer.invoke('localai:install-model', { service, modelId, options }), + getInstalledModels: (service) => ipcRenderer.invoke('localai:get-installed-models', service), + + // Legacy support (ν˜Έν™˜μ„± μœ„ν•΄ μœ μ§€) + getOllamaStatus: () => ipcRenderer.invoke('localai:get-status', 'ollama'), getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'), ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'), - installOllama: () => ipcRenderer.invoke('ollama:install'), - startOllamaService: () => ipcRenderer.invoke('ollama:start-service'), + installOllama: () => ipcRenderer.invoke('localai:install', { service: 'ollama' }), + startOllamaService: () => ipcRenderer.invoke('localai:start-service', 'ollama'), pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName), downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId), validateKey: (data) => ipcRenderer.invoke('model:validate-key', data), @@ -47,21 +56,25 @@ contextBridge.exposeInMainWorld('api', { moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y), // Listeners - onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback), - removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback), - onceOllamaInstallComplete: (callback) => ipcRenderer.once('ollama:install-complete', callback), - removeOnceOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback), - onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback), - removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback), - onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), - removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), + // LocalAI 톡합 이벀트 λ¦¬μŠ€λ„ˆ + onLocalAIProgress: (callback) => ipcRenderer.on('localai:install-progress', callback), + removeOnLocalAIProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback), + onLocalAIComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback), + removeOnLocalAIComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback), + onLocalAIError: (callback) => ipcRenderer.on('localai:error-notification', callback), + removeOnLocalAIError: (callback) => ipcRenderer.removeListener('localai:error-notification', callback), + onLocalAIModelReady: (callback) => ipcRenderer.on('localai:model-ready', callback), + removeOnLocalAIModelReady: (callback) => ipcRenderer.removeListener('localai:model-ready', callback), + // Remove all listeners (for cleanup) removeAllListeners: () => { - ipcRenderer.removeAllListeners('whisper:download-progress'); - ipcRenderer.removeAllListeners('ollama:install-progress'); - ipcRenderer.removeAllListeners('ollama:pull-progress'); - ipcRenderer.removeAllListeners('ollama:install-complete'); + // LocalAI 톡합 이벀트 + ipcRenderer.removeAllListeners('localai:install-progress'); + ipcRenderer.removeAllListeners('localai:installation-complete'); + ipcRenderer.removeAllListeners('localai:error-notification'); + ipcRenderer.removeAllListeners('localai:model-ready'); + ipcRenderer.removeAllListeners('localai:service-status-changed'); } }, @@ -239,10 +252,11 @@ contextBridge.exposeInMainWorld('api', { removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback), onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback), removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback), - onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), - removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), - onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback), - removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback) + // 톡합 LocalAI 이벀트 μ‚¬μš© + onLocalAIInstallProgress: (callback) => ipcRenderer.on('localai:install-progress', callback), + removeOnLocalAIInstallProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback), + onLocalAIInstallationComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback), + removeOnLocalAIInstallationComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback) }, // src/ui/settings/ShortCutSettingsView.js diff --git a/src/ui/app/ApiKeyHeader.js b/src/ui/app/ApiKeyHeader.js index 915ba77..26099b9 100644 --- a/src/ui/app/ApiKeyHeader.js +++ b/src/ui/app/ApiKeyHeader.js @@ -1092,6 +1092,9 @@ export class ApiKeyHeader extends LitElement { this.requestUpdate(); const progressHandler = (event, data) => { + // 톡합 LocalAI μ΄λ²€νŠΈμ—μ„œ Ollama μ§„ν–‰λ₯ λ§Œ 처리 + if (data.service !== 'ollama') return; + let baseProgress = 0; let stageTotal = 0; @@ -1137,17 +1140,21 @@ export class ApiKeyHeader extends LitElement { } }, 15000); // 15 second timeout - const completionHandler = async (event, result) => { + const completionHandler = async (event, data) => { + // 톡합 LocalAI μ΄λ²€νŠΈμ—μ„œ Ollama μ™„λ£Œλ§Œ 처리 + if (data.service !== 'ollama') return; if (operationCompleted) return; operationCompleted = true; clearTimeout(completionTimeout); - window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); - await this._handleOllamaSetupCompletion(result.success, result.error); + window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler); + // installation-complete μ΄λ²€νŠΈλŠ” 성곡을 의미 + await this._handleOllamaSetupCompletion(true); }; - window.api.apiKeyHeader.onceOllamaInstallComplete(completionHandler); - window.api.apiKeyHeader.onOllamaInstallProgress(progressHandler); + // 톡합 LocalAI 이벀트 μ‚¬μš© + window.api.apiKeyHeader.onLocalAIComplete(completionHandler); + window.api.apiKeyHeader.onLocalAIProgress(progressHandler); try { let result; @@ -1173,8 +1180,8 @@ export class ApiKeyHeader extends LitElement { operationCompleted = true; clearTimeout(completionTimeout); console.error('[ApiKeyHeader] Ollama setup failed:', error); - window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); - window.api.apiKeyHeader.removeOnceOllamaInstallComplete(completionHandler); + window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler); + window.api.apiKeyHeader.removeOnLocalAIComplete(completionHandler); await this._handleOllamaSetupCompletion(false, error.message); } } @@ -1304,7 +1311,7 @@ export class ApiKeyHeader extends LitElement { // Create robust progress handler with timeout protection progressHandler = (event, data) => { - if (data.model === modelName && !this._isOperationCancelled(modelName)) { + if (data.service === 'ollama' && data.model === modelName && !this._isOperationCancelled(modelName)) { const progress = Math.round(Math.max(0, Math.min(100, data.progress || 0))); if (progress !== this.installProgress) { @@ -1315,8 +1322,8 @@ export class ApiKeyHeader extends LitElement { } }; - // Set up progress tracking - window.api.apiKeyHeader.onOllamaPullProgress(progressHandler); + // Set up progress tracking - 톡합 LocalAI 이벀트 μ‚¬μš© + window.api.apiKeyHeader.onLocalAIProgress(progressHandler); // Execute the model pull with timeout const installPromise = window.api.apiKeyHeader.pullOllamaModel(modelName); @@ -1346,7 +1353,7 @@ export class ApiKeyHeader extends LitElement { } finally { // Comprehensive cleanup if (progressHandler) { - window.api.apiKeyHeader.removeOnOllamaPullProgress(progressHandler); + window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler); } this.installingModel = null; @@ -1376,17 +1383,17 @@ export class ApiKeyHeader extends LitElement { let progressHandler = null; try { - // Set up robust progress listener - progressHandler = (event, { modelId: id, progress }) => { - if (id === modelId) { - const cleanProgress = Math.round(Math.max(0, Math.min(100, progress || 0))); + // Set up robust progress listener - 톡합 LocalAI 이벀트 μ‚¬μš© + progressHandler = (event, data) => { + if (data.service === 'whisper' && data.model === modelId) { + const cleanProgress = Math.round(Math.max(0, Math.min(100, data.progress || 0))); this.whisperInstallingModels = { ...this.whisperInstallingModels, [modelId]: cleanProgress }; console.log(`[ApiKeyHeader] Whisper download progress: ${cleanProgress}% for ${modelId}`); this.requestUpdate(); } }; - window.api.apiKeyHeader.onWhisperDownloadProgress(progressHandler); + window.api.apiKeyHeader.onLocalAIProgress(progressHandler); // Start download with timeout protection const downloadPromise = window.api.apiKeyHeader.downloadWhisperModel(modelId); @@ -1413,7 +1420,7 @@ export class ApiKeyHeader extends LitElement { } finally { // Cleanup if (progressHandler) { - window.api.apiKeyHeader.removeOnWhisperDownloadProgress(progressHandler); + window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler); } delete this.whisperInstallingModels[modelId]; this.requestUpdate(); diff --git a/src/ui/settings/SettingsView.js b/src/ui/settings/SettingsView.js index 5d3bacb..1df713e 100644 --- a/src/ui/settings/SettingsView.js +++ b/src/ui/settings/SettingsView.js @@ -575,19 +575,50 @@ export class SettingsView extends LitElement { this.requestUpdate(); } + async loadLocalAIStatus() { + try { + // Load Ollama status + const ollamaStatus = await window.api.settingsView.getOllamaStatus(); + if (ollamaStatus?.success) { + this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running }; + this.ollamaModels = ollamaStatus.models || []; + } + + // Load Whisper models status only if Whisper is enabled + if (this.apiKeys?.whisper === 'local') { + const whisperModelsResult = await window.api.settingsView.getWhisperInstalledModels(); + if (whisperModelsResult?.success) { + const installedWhisperModels = whisperModelsResult.models; + if (this.providerConfig?.whisper) { + this.providerConfig.whisper.sttModels.forEach(m => { + const installedInfo = installedWhisperModels.find(i => i.id === m.id); + if (installedInfo) { + m.installed = installedInfo.installed; + } + }); + } + } + } + + // Trigger UI update + this.requestUpdate(); + } catch (error) { + console.error('Error loading LocalAI status:', error); + } + } + //////// after_modelStateService //////// async loadInitialData() { if (!window.api) return; this.isLoading = true; try { - const [userState, modelSettings, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = await Promise.all([ + // Load essential data first + const [userState, modelSettings, presets, contentProtection, shortcuts] = await Promise.all([ window.api.settingsView.getCurrentUser(), window.api.settingsView.getModelSettings(), // Facade call window.api.settingsView.getPresets(), window.api.settingsView.getContentProtectionStatus(), - window.api.settingsView.getCurrentShortcuts(), - window.api.settingsView.getOllamaStatus(), - window.api.settingsView.getWhisperInstalledModels() + window.api.settingsView.getCurrentShortcuts() ]); if (userState && userState.isLoggedIn) this.firebaseUser = userState; @@ -609,23 +640,9 @@ export class SettingsView extends LitElement { const firstUserPreset = this.presets.find(p => p.is_default === 0); if (firstUserPreset) this.selectedPreset = firstUserPreset; } - // Ollama status - if (ollamaStatus?.success) { - this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running }; - this.ollamaModels = ollamaStatus.models || []; - } - // Whisper status - if (whisperModelsResult?.success) { - const installedWhisperModels = whisperModelsResult.models; - if (this.providerConfig.whisper) { - this.providerConfig.whisper.sttModels.forEach(m => { - const installedInfo = installedWhisperModels.find(i => i.id === m.id); - if (installedInfo) { - m.installed = installedInfo.installed; - } - }); - } - } + + // Load LocalAI status asynchronously to improve initial load time + this.loadLocalAIStatus(); } catch (error) { console.error('Error loading initial settings data:', error); } finally { @@ -779,16 +796,16 @@ export class SettingsView extends LitElement { this.installingModels = { ...this.installingModels, [modelName]: 0 }; this.requestUpdate(); - // μ§„ν–‰λ₯  이벀트 λ¦¬μŠ€λ„ˆ μ„€μ • + // μ§„ν–‰λ₯  이벀트 λ¦¬μŠ€λ„ˆ μ„€μ • - 톡합 LocalAI 이벀트 μ‚¬μš© const progressHandler = (event, data) => { - if (data.modelId === modelName) { - this.installingModels = { ...this.installingModels, [modelName]: data.progress }; + if (data.service === 'ollama' && data.model === modelName) { + this.installingModels = { ...this.installingModels, [modelName]: data.progress || 0 }; this.requestUpdate(); } }; - // μ§„ν–‰λ₯  이벀트 λ¦¬μŠ€λ„ˆ 등둝 - window.api.settingsView.onOllamaPullProgress(progressHandler); + // 톡합 LocalAI 이벀트 λ¦¬μŠ€λ„ˆ 등둝 + window.api.settingsView.onLocalAIInstallProgress(progressHandler); try { const result = await window.api.settingsView.pullOllamaModel(modelName); @@ -805,8 +822,8 @@ export class SettingsView extends LitElement { throw new Error(result.error || 'Installation failed'); } } finally { - // μ§„ν–‰λ₯  이벀트 λ¦¬μŠ€λ„ˆ 제거 - window.api.settingsView.removeOnOllamaPullProgress(progressHandler); + // 톡합 LocalAI 이벀트 λ¦¬μŠ€λ„ˆ 제거 + window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler); } } catch (error) { console.error(`[SettingsView] Error installing model ${modelName}:`, error); @@ -821,34 +838,52 @@ export class SettingsView extends LitElement { this.requestUpdate(); try { - // Set up progress listener - const progressHandler = (event, { modelId: id, progress }) => { - if (id === modelId) { - this.installingModels = { ...this.installingModels, [modelId]: progress }; + // Set up progress listener - 톡합 LocalAI 이벀트 μ‚¬μš© + const progressHandler = (event, data) => { + if (data.service === 'whisper' && data.model === modelId) { + this.installingModels = { ...this.installingModels, [modelId]: data.progress || 0 }; this.requestUpdate(); } }; - window.api.settingsView.onWhisperDownloadProgress(progressHandler); + window.api.settingsView.onLocalAIInstallProgress(progressHandler); // Start download const result = await window.api.settingsView.downloadWhisperModel(modelId); if (result.success) { + // Update the model's installed status + if (this.providerConfig?.whisper?.sttModels) { + const modelInfo = this.providerConfig.whisper.sttModels.find(m => m.id === modelId); + if (modelInfo) { + modelInfo.installed = true; + } + } + + // Remove from installing models + delete this.installingModels[modelId]; + this.requestUpdate(); + + // Reload LocalAI status to get fresh data + await this.loadLocalAIStatus(); + // Auto-select the model after download await this.selectModel('stt', modelId); } else { + // Remove from installing models on failure too + delete this.installingModels[modelId]; + this.requestUpdate(); alert(`Failed to download Whisper model: ${result.error}`); } // Cleanup - window.api.settingsView.removeOnWhisperDownloadProgress(progressHandler); + window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler); } catch (error) { console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error); - alert(`Error downloading ${modelId}: ${error.message}`); - } finally { + // Remove from installing models on error delete this.installingModels[modelId]; this.requestUpdate(); + alert(`Error downloading ${modelId}: ${error.message}`); } } @@ -862,12 +897,6 @@ export class SettingsView extends LitElement { return null; } - async handleWhisperModelSelect(modelId) { - if (!modelId) return; - - // Select the model (will trigger download if needed) - await this.selectModel('stt', modelId); - } handleUsePicklesKey(e) { e.preventDefault() @@ -1192,12 +1221,7 @@ export class SettingsView extends LitElement { } if (id === 'whisper') { - // Special UI for Whisper with model selection - const whisperModels = config.sttModels || []; - const selectedWhisperModel = this.selectedStt && this.getProviderForModel('stt', this.selectedStt) === 'whisper' - ? this.selectedStt - : null; - + // Simplified UI for Whisper without model selection return html`
@@ -1205,51 +1229,6 @@ export class SettingsView extends LitElement {
βœ“ Whisper is enabled
- - - - - - ${Object.entries(this.installingModels).map(([modelId, progress]) => { - if (modelId.startsWith('whisper-') && progress !== undefined) { - return html` -
-
- Downloading ${modelId}... -
-
-
-
-
- `; - } - return null; - })} - @@ -1331,6 +1310,9 @@ export class SettingsView extends LitElement {
${this.availableSttModels.map(model => { const isWhisper = this.getProviderForModel('stt', model.id) === 'whisper'; + const whisperModel = isWhisper && this.providerConfig?.whisper?.sttModels + ? this.providerConfig.whisper.sttModels.find(m => m.id === model.id) + : null; const isInstalling = this.installingModels[model.id] !== undefined; const installProgress = this.installingModels[model.id] || 0; @@ -1338,10 +1320,16 @@ export class SettingsView extends LitElement {
this.selectModel('stt', model.id)}> ${model.name} - ${isWhisper && isInstalling ? html` -
-
-
+ ${isWhisper ? html` + ${isInstalling ? html` +
+
+
+ ` : whisperModel?.installed ? html` + βœ“ Installed + ` : html` + Not Installed + `} ` : ''}
`; From 9359b32c01a5102242eb129d3fb6a383dfa62e25 Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Tue, 15 Jul 2025 14:05:50 +0900 Subject: [PATCH 4/6] Add localAIManager --- .../common/services/localAIManager.js | 639 ++++++++++++++++++ 1 file changed, 639 insertions(+) create mode 100644 src/features/common/services/localAIManager.js diff --git a/src/features/common/services/localAIManager.js b/src/features/common/services/localAIManager.js new file mode 100644 index 0000000..6c73a7d --- /dev/null +++ b/src/features/common/services/localAIManager.js @@ -0,0 +1,639 @@ +const { EventEmitter } = require('events'); +const ollamaService = require('./ollamaService'); +const whisperService = require('./whisperService'); + + +//Central manager for managing Ollama and Whisper services +class LocalAIManager extends EventEmitter { + constructor() { + super(); + + // service map + this.services = { + ollama: ollamaService, + whisper: whisperService + }; + + // unified state management + this.state = { + ollama: { + installed: false, + running: false, + models: [] + }, + whisper: { + installed: false, + initialized: false, + models: [] + } + }; + + // setup event listeners + this.setupEventListeners(); + } + + + // subscribe to events from each service and re-emit as unified events + setupEventListeners() { + // ollama events + ollamaService.on('install-progress', (data) => { + this.emit('install-progress', 'ollama', data); + }); + + ollamaService.on('installation-complete', () => { + this.emit('installation-complete', 'ollama'); + this.updateServiceState('ollama'); + }); + + ollamaService.on('error', (error) => { + this.emit('error', { service: 'ollama', ...error }); + }); + + ollamaService.on('model-pull-complete', (data) => { + this.emit('model-ready', { service: 'ollama', ...data }); + this.updateServiceState('ollama'); + }); + + ollamaService.on('state-changed', (state) => { + this.emit('state-changed', 'ollama', state); + }); + + // Whisper 이벀트 + whisperService.on('install-progress', (data) => { + this.emit('install-progress', 'whisper', data); + }); + + whisperService.on('installation-complete', () => { + this.emit('installation-complete', 'whisper'); + this.updateServiceState('whisper'); + }); + + whisperService.on('error', (error) => { + this.emit('error', { service: 'whisper', ...error }); + }); + + whisperService.on('model-download-complete', (data) => { + this.emit('model-ready', { service: 'whisper', ...data }); + this.updateServiceState('whisper'); + }); + } + + /** + * μ„œλΉ„μŠ€ μ„€μΉ˜ + */ + async installService(serviceName, options = {}) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + try { + if (serviceName === 'ollama') { + return await service.handleInstall(); + } else if (serviceName === 'whisper') { + // WhisperλŠ” μžλ™ μ„€μΉ˜ + await service.initialize(); + return { success: true }; + } + } catch (error) { + this.emit('error', { + service: serviceName, + errorType: 'installation-failed', + error: error.message + }); + throw error; + } + } + + /** + * μ„œλΉ„μŠ€ μƒνƒœ 쑰회 + */ + async getServiceStatus(serviceName) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + if (serviceName === 'ollama') { + return await service.getStatus(); + } else if (serviceName === 'whisper') { + const installed = await service.isInstalled(); + const running = await service.isServiceRunning(); + const models = await service.getInstalledModels(); + return { + success: true, + installed, + running, + models + }; + } + } + + /** + * μ„œλΉ„μŠ€ μ‹œμž‘ + */ + async startService(serviceName) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + const result = await service.startService(); + await this.updateServiceState(serviceName); + return { success: result }; + } + + /** + * μ„œλΉ„μŠ€ 쀑지 + */ + async stopService(serviceName) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + let result; + if (serviceName === 'ollama') { + result = await service.shutdown(false); + } else if (serviceName === 'whisper') { + result = await service.stopService(); + } + + // μ„œλΉ„μŠ€ 쀑지 ν›„ μƒνƒœ μ—…λ°μ΄νŠΈ + await this.updateServiceState(serviceName); + + return result; + } + + /** + * λͺ¨λΈ μ„€μΉ˜/λ‹€μš΄λ‘œλ“œ + */ + async installModel(serviceName, modelId, options = {}) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + if (serviceName === 'ollama') { + return await service.pullModel(modelId); + } else if (serviceName === 'whisper') { + return await service.downloadModel(modelId); + } + } + + /** + * μ„€μΉ˜λœ λͺ¨λΈ λͺ©λ‘ 쑰회 + */ + async getInstalledModels(serviceName) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + if (serviceName === 'ollama') { + return await service.getAllModelsWithStatus(); + } else if (serviceName === 'whisper') { + return await service.getInstalledModels(); + } + } + + /** + * λͺ¨λΈ μ›Œλ°μ—… (Ollama μ „μš©) + */ + async warmUpModel(modelName, forceRefresh = false) { + return await ollamaService.warmUpModel(modelName, forceRefresh); + } + + /** + * μžλ™ μ›Œλ°μ—… (Ollama μ „μš©) + */ + async autoWarmUp() { + return await ollamaService.autoWarmUpSelectedModel(); + } + + /** + * 진단 μ‹€ν–‰ + */ + async runDiagnostics(serviceName) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + const diagnostics = { + service: serviceName, + timestamp: new Date().toISOString(), + checks: {} + }; + + try { + // 1. μ„€μΉ˜ μƒνƒœ 확인 + diagnostics.checks.installation = { + check: 'Installation', + status: await service.isInstalled() ? 'pass' : 'fail', + details: {} + }; + + // 2. μ„œλΉ„μŠ€ μ‹€ν–‰ μƒνƒœ + diagnostics.checks.running = { + check: 'Service Running', + status: await service.isServiceRunning() ? 'pass' : 'fail', + details: {} + }; + + // 3. 포트 μ—°κ²° ν…ŒμŠ€νŠΈ 및 상세 health check (Ollama) + if (serviceName === 'ollama') { + try { + // Use comprehensive health check + const health = await service.healthCheck(); + diagnostics.checks.health = { + check: 'Service Health', + status: health.healthy ? 'pass' : 'fail', + details: health + }; + + // Legacy port check for compatibility + diagnostics.checks.port = { + check: 'Port Connectivity', + status: health.checks.apiResponsive ? 'pass' : 'fail', + details: { connected: health.checks.apiResponsive } + }; + } catch (error) { + diagnostics.checks.health = { + check: 'Service Health', + status: 'fail', + details: { error: error.message } + }; + diagnostics.checks.port = { + check: 'Port Connectivity', + status: 'fail', + details: { error: error.message } + }; + } + + // 4. λͺ¨λΈ λͺ©λ‘ + if (diagnostics.checks.running.status === 'pass') { + try { + const models = await service.getInstalledModels(); + diagnostics.checks.models = { + check: 'Installed Models', + status: 'pass', + details: { count: models.length, models: models.map(m => m.name) } + }; + + // 5. μ›Œλ°μ—… μƒνƒœ + const warmupStatus = await service.getWarmUpStatus(); + diagnostics.checks.warmup = { + check: 'Model Warm-up', + status: 'pass', + details: warmupStatus + }; + } catch (error) { + diagnostics.checks.models = { + check: 'Installed Models', + status: 'fail', + details: { error: error.message } + }; + } + } + } + + // 4. Whisper νŠΉν™” 진단 + if (serviceName === 'whisper') { + // λ°”μ΄λ„ˆλ¦¬ 확인 + diagnostics.checks.binary = { + check: 'Whisper Binary', + status: service.whisperPath ? 'pass' : 'fail', + details: { path: service.whisperPath } + }; + + // λͺ¨λΈ 디렉토리 + diagnostics.checks.modelDir = { + check: 'Model Directory', + status: service.modelsDir ? 'pass' : 'fail', + details: { path: service.modelsDir } + }; + } + + // 전체 진단 κ²°κ³Ό + const allChecks = Object.values(diagnostics.checks); + diagnostics.summary = { + total: allChecks.length, + passed: allChecks.filter(c => c.status === 'pass').length, + failed: allChecks.filter(c => c.status === 'fail').length, + overallStatus: allChecks.every(c => c.status === 'pass') ? 'healthy' : 'unhealthy' + }; + + } catch (error) { + diagnostics.error = error.message; + diagnostics.summary = { + overallStatus: 'error' + }; + } + + return diagnostics; + } + + /** + * μ„œλΉ„μŠ€ 볡ꡬ + */ + async repairService(serviceName) { + const service = this.services[serviceName]; + if (!service) { + throw new Error(`Unknown service: ${serviceName}`); + } + + console.log(`[LocalAIManager] Starting repair for ${serviceName}...`); + const repairLog = []; + + try { + // 1. 진단 μ‹€ν–‰ + repairLog.push('Running diagnostics...'); + const diagnostics = await this.runDiagnostics(serviceName); + + if (diagnostics.summary.overallStatus === 'healthy') { + repairLog.push('Service is already healthy, no repair needed'); + return { + success: true, + repairLog, + diagnostics + }; + } + + // 2. μ„€μΉ˜ 문제 ν•΄κ²° + if (diagnostics.checks.installation?.status === 'fail') { + repairLog.push('Installation missing, attempting to install...'); + try { + await this.installService(serviceName); + repairLog.push('Installation completed'); + } catch (error) { + repairLog.push(`Installation failed: ${error.message}`); + throw error; + } + } + + // 3. μ„œλΉ„μŠ€ μž¬μ‹œμž‘ + if (diagnostics.checks.running?.status === 'fail') { + repairLog.push('Service not running, attempting to start...'); + + // μ’…λ£Œ μ‹œλ„ + try { + await this.stopService(serviceName); + repairLog.push('Stopped existing service'); + } catch (error) { + repairLog.push('Service was not running'); + } + + // μž μ‹œ λŒ€κΈ° + await new Promise(resolve => setTimeout(resolve, 2000)); + + // μ‹œμž‘ + try { + await this.startService(serviceName); + repairLog.push('Service started successfully'); + } catch (error) { + repairLog.push(`Failed to start service: ${error.message}`); + throw error; + } + } + + // 4. 포트 문제 ν•΄κ²° (Ollama) + if (serviceName === 'ollama' && diagnostics.checks.port?.status === 'fail') { + repairLog.push('Port connectivity issue detected'); + + // ν”„λ‘œμ„ΈμŠ€ κ°•μ œ μ’…λ£Œ + if (process.platform === 'darwin') { + try { + const { exec } = require('child_process'); + const { promisify } = require('util'); + const execAsync = promisify(exec); + await execAsync('pkill -f ollama'); + repairLog.push('Killed stale Ollama processes'); + } catch (error) { + repairLog.push('No stale processes found'); + } + } + else if (process.platform === 'win32') { + try { + const { exec } = require('child_process'); + const { promisify } = require('util'); + const execAsync = promisify(exec); + await execAsync('taskkill /F /IM ollama.exe'); + repairLog.push('Killed stale Ollama processes'); + } catch (error) { + repairLog.push('No stale processes found'); + } + } + else if (process.platform === 'linux') { + try { + const { exec } = require('child_process'); + const { promisify } = require('util'); + const execAsync = promisify(exec); + await execAsync('pkill -f ollama'); + repairLog.push('Killed stale Ollama processes'); + } catch (error) { + repairLog.push('No stale processes found'); + } + } + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // μž¬μ‹œμž‘ + await this.startService(serviceName); + repairLog.push('Restarted service after port cleanup'); + } + + // 5. Whisper νŠΉν™” 볡ꡬ + if (serviceName === 'whisper') { + // μ„Έμ…˜ 정리 + if (diagnostics.checks.running?.status === 'pass') { + repairLog.push('Cleaning up Whisper sessions...'); + await service.cleanup(); + repairLog.push('Sessions cleaned up'); + } + + // μ΄ˆκΈ°ν™” + if (!service.installState.isInitialized) { + repairLog.push('Re-initializing Whisper...'); + await service.initialize(); + repairLog.push('Whisper re-initialized'); + } + } + + // 6. μ΅œμ’… μƒνƒœ 확인 + repairLog.push('Verifying repair...'); + const finalDiagnostics = await this.runDiagnostics(serviceName); + + const success = finalDiagnostics.summary.overallStatus === 'healthy'; + repairLog.push(success ? 'Repair successful!' : 'Repair failed - manual intervention may be required'); + + // 성곡 μ‹œ μƒνƒœ μ—…λ°μ΄νŠΈ + if (success) { + await this.updateServiceState(serviceName); + } + + return { + success, + repairLog, + diagnostics: finalDiagnostics + }; + + } catch (error) { + repairLog.push(`Repair error: ${error.message}`); + return { + success: false, + repairLog, + error: error.message + }; + } + } + + /** + * μƒνƒœ μ—…λ°μ΄νŠΈ + */ + async updateServiceState(serviceName) { + try { + const status = await this.getServiceStatus(serviceName); + this.state[serviceName] = status; + + // μƒνƒœ λ³€κ²½ 이벀트 λ°œν–‰ + this.emit('state-changed', serviceName, status); + } catch (error) { + console.error(`[LocalAIManager] Failed to update ${serviceName} state:`, error); + } + } + + /** + * 전체 μƒνƒœ 쑰회 + */ + async getAllServiceStates() { + const states = {}; + + for (const serviceName of Object.keys(this.services)) { + try { + states[serviceName] = await this.getServiceStatus(serviceName); + } catch (error) { + states[serviceName] = { + success: false, + error: error.message + }; + } + } + + return states; + } + + /** + * 주기적 μƒνƒœ 동기화 μ‹œμž‘ + */ + startPeriodicSync(interval = 30000) { + if (this.syncInterval) { + return; + } + + this.syncInterval = setInterval(async () => { + for (const serviceName of Object.keys(this.services)) { + await this.updateServiceState(serviceName); + } + }, interval); + + // 각 μ„œλΉ„μŠ€μ˜ 주기적 동기화도 μ‹œμž‘ + ollamaService.startPeriodicSync(); + } + + /** + * 주기적 μƒνƒœ 동기화 쀑지 + */ + stopPeriodicSync() { + if (this.syncInterval) { + clearInterval(this.syncInterval); + this.syncInterval = null; + } + + // 각 μ„œλΉ„μŠ€μ˜ 주기적 동기화도 쀑지 + ollamaService.stopPeriodicSync(); + } + + /** + * 전체 μ’…λ£Œ + */ + async shutdown() { + this.stopPeriodicSync(); + + const results = {}; + for (const [serviceName, service] of Object.entries(this.services)) { + try { + if (serviceName === 'ollama') { + results[serviceName] = await service.shutdown(false); + } else if (serviceName === 'whisper') { + await service.cleanup(); + results[serviceName] = true; + } + } catch (error) { + results[serviceName] = false; + console.error(`[LocalAIManager] Failed to shutdown ${serviceName}:`, error); + } + } + + return results; + } + + /** + * μ—λŸ¬ 처리 + */ + async handleError(serviceName, errorType, details = {}) { + console.error(`[LocalAIManager] Error in ${serviceName}: ${errorType}`, details); + + // μ„œλΉ„μŠ€λ³„ μ—λŸ¬ 처리 + switch(errorType) { + case 'installation-failed': + // μ„€μΉ˜ μ‹€νŒ¨ μ‹œ 이벀트 λ°œμƒ + this.emit('error-occurred', { + service: serviceName, + errorType, + error: details.error || 'Installation failed', + canRetry: true + }); + break; + + case 'model-pull-failed': + case 'model-download-failed': + // λͺ¨λΈ λ‹€μš΄λ‘œλ“œ μ‹€νŒ¨ + this.emit('error-occurred', { + service: serviceName, + errorType, + model: details.model, + error: details.error || 'Model download failed', + canRetry: true + }); + break; + + case 'service-not-responding': + // μ„œλΉ„μŠ€ λ°˜μ‘ μ—†μŒ + console.log(`[LocalAIManager] Attempting to repair ${serviceName}...`); + const repairResult = await this.repairService(serviceName); + + this.emit('error-occurred', { + service: serviceName, + errorType, + error: details.error || 'Service not responding', + repairAttempted: true, + repairSuccessful: repairResult.success + }); + break; + + default: + // 기타 μ—λŸ¬ + this.emit('error-occurred', { + service: serviceName, + errorType, + error: details.error || `Unknown error: ${errorType}`, + canRetry: false + }); + } + } +} + +// 싱글톀 +const localAIManager = new LocalAIManager(); +module.exports = localAIManager; \ No newline at end of file From 7f98acb5e34055365fee512f96ff8f42aec33f3f Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Tue, 15 Jul 2025 15:32:24 +0900 Subject: [PATCH 5/6] whisper install fix --- src/bridge/featureBridge.js | 2 -- src/features/common/services/modelStateService.js | 8 ++++++-- src/features/common/services/whisperService.js | 4 ++-- src/ui/settings/SettingsView.js | 3 ++- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index b5c13eb..afe12d7 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -7,9 +7,7 @@ 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 windowBridge = require('./windowBridge'); const localAIManager = require('../features/common/services/localAIManager'); - const askService = require('../features/ask/askService'); const listenService = require('../features/listen/listenService'); const permissionService = require('../features/common/services/permissionService'); diff --git a/src/features/common/services/modelStateService.js b/src/features/common/services/modelStateService.js index 46242ae..57e18be 100644 --- a/src/features/common/services/modelStateService.js +++ b/src/features/common/services/modelStateService.js @@ -426,7 +426,7 @@ class ModelStateService extends EventEmitter { } } - setFirebaseVirtualKey(virtualKey) { + async setFirebaseVirtualKey(virtualKey) { console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`); this.state.apiKeys['openai-glass'] = virtualKey; @@ -448,8 +448,12 @@ class ModelStateService extends EventEmitter { this._autoSelectAvailableModels(); } - this._saveState(); + await this._saveState(); this._logCurrentSelection(); + + // Emit events to update UI + this.emit('state-updated', this.state); + this.emit('settings-updated'); } async setApiKey(provider, key) { diff --git a/src/features/common/services/whisperService.js b/src/features/common/services/whisperService.js index e676913..7d285a9 100644 --- a/src/features/common/services/whisperService.js +++ b/src/features/common/services/whisperService.js @@ -643,7 +643,7 @@ class WhisperService extends EventEmitter { async installWindows() { console.log('[WhisperService] Installing Whisper on Windows...'); const version = 'v1.7.6'; - const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-x64.zip`; + const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-x64.zip`; const tempFile = path.join(this.tempDir, 'whisper-binary.zip'); try { @@ -757,7 +757,7 @@ class WhisperService extends EventEmitter { async installLinux() { console.log('[WhisperService] Installing Whisper on Linux...'); const version = 'v1.7.6'; - const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`; + const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`; const tempFile = path.join(this.tempDir, 'whisper-binary.tar.gz'); try { diff --git a/src/ui/settings/SettingsView.js b/src/ui/settings/SettingsView.js index 1df713e..a8b4502 100644 --- a/src/ui/settings/SettingsView.js +++ b/src/ui/settings/SettingsView.js @@ -956,7 +956,8 @@ export class SettingsView extends LitElement { this.firebaseUser = null; } this.loadAutoUpdateSetting(); - this.requestUpdate(); + // Reload model settings when user state changes (Firebase login/logout) + this.loadInitialData(); }; this._settingsUpdatedListener = (event, settings) => { From fc16532cd967b73ccdb9fa78aabf1a59ad293b15 Mon Sep 17 00:00:00 2001 From: jhyang0 Date: Tue, 15 Jul 2025 15:48:45 +0900 Subject: [PATCH 6/6] whisper install url fix --- src/features/common/services/whisperService.js | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/common/services/whisperService.js b/src/features/common/services/whisperService.js index 7d285a9..da7272d 100644 --- a/src/features/common/services/whisperService.js +++ b/src/features/common/services/whisperService.js @@ -285,7 +285,7 @@ class WhisperService extends EventEmitter { // Windowsμ—μ„œλŠ” .exe ν™•μž₯자 ν•„μš” const platform = this.getPlatform(); - const whisperExecutable = platform === 'win32' ? 'whisper.exe' : 'whisper'; + const whisperExecutable = platform === 'win32' ? 'whisper-whisper.exe' : 'whisper'; this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable); await this.ensureDirectories(); @@ -643,7 +643,7 @@ class WhisperService extends EventEmitter { async installWindows() { console.log('[WhisperService] Installing Whisper on Windows...'); const version = 'v1.7.6'; - const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-x64.zip`; + const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-bin-x64.zip`; const tempFile = path.join(this.tempDir, 'whisper-binary.zip'); try { @@ -721,8 +721,7 @@ class WhisperService extends EventEmitter { if (item.isDirectory()) { const subExecutables = await this.findWhisperExecutables(fullPath); executables.push(...subExecutables); - } else if (item.isFile() && (item.name === 'whisper.exe' || item.name === 'main.exe')) { - // main.exe도 포함 (일뢀 λΉŒλ“œμ—μ„œ whisper μ‹€ν–‰νŒŒμΌμ΄ main.exe둜 λͺ…λͺ…됨) + } else if (item.isFile() && (item.name === 'whisper-whisper.exe' || item.name === 'whisper.exe' || item.name === 'main.exe')) { executables.push(fullPath); } }