Merge branch 'refactor/localmodel' into feature/encryption
This commit is contained in:
		
						commit
						a27ab05fa8
					
				
							
								
								
									
										78
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										78
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -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)",
 | 
			
		||||
 | 
			
		||||
@ -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",
 | 
			
		||||
 | 
			
		||||
@ -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,7 +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 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');
 | 
			
		||||
@ -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());
 | 
			
		||||
@ -116,6 +119,115 @@ module.exports = {
 | 
			
		||||
    ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
 | 
			
		||||
    ipcMain.handle('model:re-initialize-state', () => modelStateService.initialize());
 | 
			
		||||
 | 
			
		||||
    // 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.');
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,20 +1,23 @@
 | 
			
		||||
// 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());
 | 
			
		||||
    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 +27,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) {
 | 
			
		||||
 | 
			
		||||
@ -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() });
 | 
			
		||||
 | 
			
		||||
@ -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'
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										111
									
								
								src/features/common/ai/providers/deepgram.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										111
									
								
								src/features/common/ai/providers/deepgram.js
									
									
									
									
									
										Normal file
									
								
							@ -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
 | 
			
		||||
};
 | 
			
		||||
@ -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();
 | 
			
		||||
 | 
			
		||||
@ -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일 경우 체크섬 검증 스킵됨
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -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' },
 | 
			
		||||
 | 
			
		||||
@ -57,6 +57,24 @@ const providerSettingsRepositoryAdapter = {
 | 
			
		||||
        // as it's part of the local-first boot sequence.
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return await sqliteRepository.getRawApiKeysByUid(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);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
const sqliteClient = require('../../services/sqliteClient');
 | 
			
		||||
const encryptionService = require('../../services/encryptionService');
 | 
			
		||||
 | 
			
		||||
function getByProvider(uid, provider) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
@ -20,23 +19,30 @@ function getAllByUid(uid) {
 | 
			
		||||
    
 | 
			
		||||
    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();
 | 
			
		||||
    
 | 
			
		||||
    // 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
 | 
			
		||||
    `);
 | 
			
		||||
    
 | 
			
		||||
@ -46,6 +52,8 @@ function upsert(uid, provider, settings) {
 | 
			
		||||
        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
 | 
			
		||||
    );
 | 
			
		||||
@ -73,11 +81,80 @@ function getRawApiKeysByUid(uid) {
 | 
			
		||||
    return stmt.all(uid);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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,
 | 
			
		||||
    getRawApiKeysByUid
 | 
			
		||||
    getRawApiKeysByUid,
 | 
			
		||||
    getActiveProvider,
 | 
			
		||||
    setActiveProvider,
 | 
			
		||||
    getActiveSettings
 | 
			
		||||
}; 
 | 
			
		||||
@ -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
 | 
			
		||||
}; 
 | 
			
		||||
@ -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
 | 
			
		||||
}; 
 | 
			
		||||
@ -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
 | 
			
		||||
}; 
 | 
			
		||||
@ -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');
 | 
			
		||||
const permissionService = require('./permissionService');
 | 
			
		||||
 | 
			
		||||
async function getVirtualKeyByEmail(email, idToken) {
 | 
			
		||||
@ -48,7 +47,6 @@ class AuthService {
 | 
			
		||||
 | 
			
		||||
        sessionRepository.setAuthService(this);
 | 
			
		||||
        providerSettingsRepository.setAuthService(this);
 | 
			
		||||
        userModelSelectionsRepository.setAuthService(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize() {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										639
									
								
								src/features/common/services/localAIManager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										639
									
								
								src/features/common/services/localAIManager.js
									
									
									
									
									
										Normal file
									
								
							@ -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;
 | 
			
		||||
@ -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;
 | 
			
		||||
@ -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');
 | 
			
		||||
}
 | 
			
		||||
@ -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');
 | 
			
		||||
const authService = require('./authService');
 | 
			
		||||
 | 
			
		||||
class ModelStateService extends EventEmitter {
 | 
			
		||||
@ -29,25 +27,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;
 | 
			
		||||
@ -96,6 +123,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();
 | 
			
		||||
@ -125,17 +212,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
 | 
			
		||||
@ -169,11 +265,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 = {
 | 
			
		||||
@ -217,6 +313,9 @@ class ModelStateService extends EventEmitter {
 | 
			
		||||
            console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Check for user_model_selections migration first
 | 
			
		||||
        await this._migrateUserModelSelections();
 | 
			
		||||
        
 | 
			
		||||
        // Try to load from database first
 | 
			
		||||
        await this._loadStateFromDatabase();
 | 
			
		||||
        
 | 
			
		||||
@ -252,17 +351,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}`);
 | 
			
		||||
@ -315,7 +435,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;
 | 
			
		||||
        
 | 
			
		||||
@ -337,8 +457,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) {
 | 
			
		||||
@ -353,8 +477,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) {
 | 
			
		||||
@ -372,8 +496,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;
 | 
			
		||||
@ -515,12 +639,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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -587,7 +720,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;
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -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();
 | 
			
		||||
@ -65,16 +285,21 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
            
 | 
			
		||||
            // 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();
 | 
			
		||||
            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;
 | 
			
		||||
@ -349,7 +643,7 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
    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-bin-x64.zip`;
 | 
			
		||||
        const tempFile = path.join(this.tempDir, 'whisper-binary.zip');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
@ -427,8 +721,7 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
                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);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
@ -463,7 +756,7 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
    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 {
 | 
			
		||||
@ -493,6 +786,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;
 | 
			
		||||
@ -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,20 +108,24 @@ 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 });
 | 
			
		||||
                    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':
 | 
			
		||||
                    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;
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -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',
 | 
			
		||||
@ -54,17 +53,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');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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(); 
 | 
			
		||||
 | 
			
		||||
const shortcutsService = new ShortcutsService();
 | 
			
		||||
 | 
			
		||||
module.exports = shortcutsService;
 | 
			
		||||
@ -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');
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -98,11 +111,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),
 | 
			
		||||
@ -218,8 +234,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),
 | 
			
		||||
@ -241,29 +257,27 @@ 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
 | 
			
		||||
  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),    
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
 | 
			
		||||
@ -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 {
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="header-actions" @click=${() => this.invoke('toggle-all-windows-visibility')}>
 | 
			
		||||
                <div class="header-actions" @click=${() => this._handleToggleAllWindowsVisibility()}>
 | 
			
		||||
                    <div class="action-text">
 | 
			
		||||
                        <div class="action-text-content">Show/Hide</div>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
@ -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()
 | 
			
		||||
@ -879,7 +908,7 @@ export class SettingsView extends LitElement {
 | 
			
		||||
    //////// after_modelStateService ////////
 | 
			
		||||
 | 
			
		||||
    openShortcutEditor() {
 | 
			
		||||
        window.api.settingsView.openShortcutEditor();
 | 
			
		||||
        window.api.settingsView.openShortcutSettingsWindow();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
@ -927,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) => {
 | 
			
		||||
@ -1019,13 +1049,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 },
 | 
			
		||||
@ -1198,12 +1222,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`
 | 
			
		||||
                                <div class="provider-key-group">
 | 
			
		||||
                                    <label>${config.name} (Local STT)</label>
 | 
			
		||||
@ -1211,51 +1230,6 @@ export class SettingsView extends LitElement {
 | 
			
		||||
                                        <div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8); margin-bottom: 8px;">
 | 
			
		||||
                                            ✓ Whisper is enabled
 | 
			
		||||
                                        </div>
 | 
			
		||||
                                        
 | 
			
		||||
                                        <!-- Whisper Model Selection Dropdown -->
 | 
			
		||||
                                        <label style="font-size: 10px; margin-top: 8px;">Select Model:</label>
 | 
			
		||||
                                        <select 
 | 
			
		||||
                                            class="model-dropdown" 
 | 
			
		||||
                                            style="width: 100%; padding: 6px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; font-size: 11px; margin-bottom: 8px;"
 | 
			
		||||
                                            @change=${(e) => this.handleWhisperModelSelect(e.target.value)}
 | 
			
		||||
                                            .value=${selectedWhisperModel || ''}
 | 
			
		||||
                                        >
 | 
			
		||||
                                            <option value="">Choose a model...</option>
 | 
			
		||||
                                            ${whisperModels.map(model => {
 | 
			
		||||
                                                const isInstalling = this.installingModels[model.id] !== undefined;
 | 
			
		||||
                                                const progress = this.installingModels[model.id] || 0;
 | 
			
		||||
                                                
 | 
			
		||||
                                                let statusText = '';
 | 
			
		||||
                                                if (isInstalling) {
 | 
			
		||||
                                                    statusText = ` (Downloading ${progress}%)`;
 | 
			
		||||
                                                } else if (model.installed) {
 | 
			
		||||
                                                    statusText = ' (Installed)';
 | 
			
		||||
                                                }
 | 
			
		||||
                                                
 | 
			
		||||
                                                return html`
 | 
			
		||||
                                                    <option value="${model.id}" ?disabled=${isInstalling}>
 | 
			
		||||
                                                        ${model.name}${statusText}
 | 
			
		||||
                                                    </option>
 | 
			
		||||
                                                `;
 | 
			
		||||
                                            })}
 | 
			
		||||
                                        </select>
 | 
			
		||||
                                        
 | 
			
		||||
                                        ${Object.entries(this.installingModels).map(([modelId, progress]) => {
 | 
			
		||||
                                            if (modelId.startsWith('whisper-') && progress !== undefined) {
 | 
			
		||||
                                                return html`
 | 
			
		||||
                                                    <div style="margin: 8px 0;">
 | 
			
		||||
                                                        <div style="font-size: 10px; color: rgba(255,255,255,0.7); margin-bottom: 4px;">
 | 
			
		||||
                                                            Downloading ${modelId}...
 | 
			
		||||
                                                        </div>
 | 
			
		||||
                                                        <div class="install-progress" style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden;">
 | 
			
		||||
                                                            <div class="install-progress-bar" style="height: 100%; background: rgba(0, 122, 255, 0.8); width: ${progress}%; transition: width 0.3s ease;"></div>
 | 
			
		||||
                                                        </div>
 | 
			
		||||
                                                    </div>
 | 
			
		||||
                                                `;
 | 
			
		||||
                                            }
 | 
			
		||||
                                            return null;
 | 
			
		||||
                                        })}
 | 
			
		||||
                                        
 | 
			
		||||
                                        <button class="settings-button full-width danger" @click=${() => this.handleClearKey(id)}>
 | 
			
		||||
                                            Disable Whisper
 | 
			
		||||
                                        </button>
 | 
			
		||||
@ -1337,6 +1311,9 @@ export class SettingsView extends LitElement {
 | 
			
		||||
                        <div class="model-list">
 | 
			
		||||
                            ${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;
 | 
			
		||||
                                
 | 
			
		||||
@ -1344,10 +1321,16 @@ export class SettingsView extends LitElement {
 | 
			
		||||
                                    <div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}" 
 | 
			
		||||
                                         @click=${() => this.selectModel('stt', model.id)}>
 | 
			
		||||
                                        <span>${model.name}</span>
 | 
			
		||||
                                        ${isWhisper && isInstalling ? html`
 | 
			
		||||
                                            <div class="install-progress">
 | 
			
		||||
                                                <div class="install-progress-bar" style="width: ${installProgress}%"></div>
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                        ${isWhisper ? html`
 | 
			
		||||
                                            ${isInstalling ? html`
 | 
			
		||||
                                                <div class="install-progress">
 | 
			
		||||
                                                    <div class="install-progress-bar" style="width: ${installProgress}%"></div>
 | 
			
		||||
                                                </div>
 | 
			
		||||
                                            ` : whisperModel?.installed ? html`
 | 
			
		||||
                                                <span class="model-status installed">✓ Installed</span>
 | 
			
		||||
                                            ` : html`
 | 
			
		||||
                                                <span class="model-status not-installed">Not Installed</span>
 | 
			
		||||
                                            `}
 | 
			
		||||
                                        ` : ''}
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                `;
 | 
			
		||||
 | 
			
		||||
@ -179,7 +179,7 @@ export class ShortcutSettingsView extends LitElement {
 | 
			
		||||
 | 
			
		||||
    handleClose() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        window.api.shortcutSettingsView.closeShortcutEditor();
 | 
			
		||||
        window.api.shortcutSettingsView.closeShortcutSettingsWindow();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleResetToDefault() {
 | 
			
		||||
 | 
			
		||||
@ -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 });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    /**
 | 
			
		||||
 | 
			
		||||
@ -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<string, BrowserWindow>} 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,
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user