Compare commits
	
		
			40 Commits
		
	
	
		
			main
			...
			refactor/f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					d936af46a3 | ||
| 
						 | 
					586d44e57b | ||
| 
						 | 
					5c2f9c1eb7 | ||
| 
						 | 
					3c0654a0d4 | ||
| 
						 | 
					9ec8df0548 | ||
| 
						 | 
					73a6e1345e | ||
| 
						 | 
					bb9061316c | ||
| 
						 | 
					09aaf1f62d | ||
| 
						 | 
					3d7738826c | ||
| 
						 | 
					6d708d6dcd | ||
| 
						 | 
					08c5aa4f6d | ||
| 
						 | 
					093f233f5a | ||
| 
						 | 
					c0edcfb0f9 | ||
| 
						 | 
					bf13a865ba | ||
| 
						 | 
					2063ab73ee | ||
| 
						 | 
					0992cd4668 | ||
| 
						 | 
					18154e221c | ||
| 
						 | 
					a951d02a59 | ||
| 
						 | 
					c5b190b522 | ||
| 
						 | 
					bf20d002ba | ||
| 
						 | 
					2cf71f1034 | ||
| 
						 | 
					f60e73c08c | ||
| 
						 | 
					3bece73f78 | ||
| 
						 | 
					fc3c5e056a | ||
| 
						 | 
					b2475c0940 | ||
| 
						 | 
					817a8c5165 | ||
| 
						 | 
					b5b6f40995 | ||
| 
						 | 
					27f6f0e68e | ||
| 
						 | 
					c948d4ed08 | ||
| 
						 | 
					8402e7d296 | ||
| 
						 | 
					5f007096d7 | ||
| 
						 | 
					6faa5d7ec7 | ||
| 
						 | 
					69053f4c0f | ||
| 
						 | 
					d6ee8e07c5 | ||
| 
						 | 
					8c5b10281a | ||
| 
						 | 
					43a9ce154f | ||
| 
						 | 
					9b409c58fe | ||
| 
						 | 
					9eee95221e | ||
| 
						 | 
					beedb909f9 | ||
| 
						 | 
					1bdc5fd1bd | 
@ -1,2 +1,2 @@
 | 
			
		||||
src/ui/assets
 | 
			
		||||
src/assets
 | 
			
		||||
node_modules
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								aec
									
									
									
									
									
								
							
							
								
								
								
								
								
								
									
									
								
							
						
						
									
										2
									
								
								aec
									
									
									
									
									
								
							@ -1 +1 @@
 | 
			
		||||
Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163
 | 
			
		||||
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f
 | 
			
		||||
@ -39,7 +39,7 @@ asarUnpack:
 | 
			
		||||
 | 
			
		||||
# Windows configuration
 | 
			
		||||
win:
 | 
			
		||||
    icon: src/ui/assets/logo.ico
 | 
			
		||||
    icon: src/assets/logo.ico
 | 
			
		||||
    target:
 | 
			
		||||
        - target: nsis
 | 
			
		||||
          arch: x64
 | 
			
		||||
@ -67,7 +67,7 @@ mac:
 | 
			
		||||
    # The application category type
 | 
			
		||||
    category: public.app-category.utilities
 | 
			
		||||
    # Path to the .icns icon file
 | 
			
		||||
    icon: src/ui/assets/logo.icns
 | 
			
		||||
    icon: src/assets/logo.icns
 | 
			
		||||
    # Minimum macOS version (supports both Intel and Apple Silicon)
 | 
			
		||||
    minimumSystemVersion: '11.0'
 | 
			
		||||
    hardenedRuntime: true
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										78
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										78
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							@ -11,7 +11,6 @@
 | 
			
		||||
            "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",
 | 
			
		||||
@ -55,50 +54,6 @@
 | 
			
		||||
                "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,
 | 
			
		||||
@ -3037,15 +2992,6 @@
 | 
			
		||||
            "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,
 | 
			
		||||
@ -3074,12 +3020,6 @@
 | 
			
		||||
                "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",
 | 
			
		||||
@ -3138,15 +3078,6 @@
 | 
			
		||||
                "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,
 | 
			
		||||
@ -3804,15 +3735,6 @@
 | 
			
		||||
                "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,7 +33,6 @@
 | 
			
		||||
    "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,26 +1,26 @@
 | 
			
		||||
// src/bridge/featureBridge.js
 | 
			
		||||
const { ipcMain, app, BrowserWindow } = require('electron');
 | 
			
		||||
const { ipcMain, app } = require('electron');
 | 
			
		||||
const settingsService = require('../features/settings/settingsService');
 | 
			
		||||
const authService = require('../features/common/services/authService');
 | 
			
		||||
const whisperService = require('../features/common/services/whisperService');
 | 
			
		||||
const ollamaService = require('../features/common/services/ollamaService');
 | 
			
		||||
const modelStateService = require('../features/common/services/modelStateService');
 | 
			
		||||
const shortcutsService = require('../features/shortcuts/shortcutsService');
 | 
			
		||||
const presetRepository = require('../features/common/repositories/preset');
 | 
			
		||||
const 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');
 | 
			
		||||
const encryptionService = require('../features/common/services/encryptionService');
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  // Renderer로부터의 요청을 수신하고 서비스로 전달
 | 
			
		||||
  // Renderer로부터의 요청을 수신
 | 
			
		||||
  initialize() {
 | 
			
		||||
    
 | 
			
		||||
    // Settings Service
 | 
			
		||||
    ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
 | 
			
		||||
    ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
 | 
			
		||||
    ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => await settingsService.setAutoUpdateSetting(isEnabled));  
 | 
			
		||||
    ipcMain.handle('settings:get-model-settings', async () => await settingsService.getModelSettings());
 | 
			
		||||
    ipcMain.handle('settings:validate-and-save-key', async (e, { provider, key }) => await settingsService.validateAndSaveKey(provider, key));
 | 
			
		||||
    ipcMain.handle('settings:clear-api-key', async (e, { provider }) => await settingsService.clearApiKey(provider));
 | 
			
		||||
    ipcMain.handle('settings:set-selected-model', async (e, { type, modelId }) => await settingsService.setSelectedModel(type, modelId));    
 | 
			
		||||
 | 
			
		||||
@ -29,24 +29,18 @@ module.exports = {
 | 
			
		||||
    ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama());
 | 
			
		||||
 | 
			
		||||
    // Shortcuts
 | 
			
		||||
    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());
 | 
			
		||||
    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));
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // Permissions
 | 
			
		||||
    ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
 | 
			
		||||
    ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
 | 
			
		||||
    ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section));
 | 
			
		||||
    ipcMain.handle('mark-keychain-completed', async () => await permissionService.markKeychainCompleted());
 | 
			
		||||
    ipcMain.handle('check-keychain-completed', async () => await permissionService.checkKeychainCompleted());
 | 
			
		||||
    ipcMain.handle('initialize-encryption-key', async () => {
 | 
			
		||||
        const userId = authService.getCurrentUserId();
 | 
			
		||||
        await encryptionService.initializeKey(userId);
 | 
			
		||||
        return { success: true };
 | 
			
		||||
    });
 | 
			
		||||
    ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
 | 
			
		||||
    ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // User/Auth
 | 
			
		||||
    ipcMain.handle('get-current-user', () => authService.getCurrentUser());
 | 
			
		||||
@ -57,7 +51,7 @@ module.exports = {
 | 
			
		||||
    ipcMain.handle('quit-application', () => app.quit());
 | 
			
		||||
 | 
			
		||||
    // Whisper
 | 
			
		||||
    ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(modelId));
 | 
			
		||||
    ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(event, modelId));
 | 
			
		||||
    ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
 | 
			
		||||
       
 | 
			
		||||
    // General
 | 
			
		||||
@ -66,24 +60,23 @@ module.exports = {
 | 
			
		||||
 | 
			
		||||
    // Ollama
 | 
			
		||||
    ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus());
 | 
			
		||||
    ipcMain.handle('ollama:install', async () => await ollamaService.handleInstall());
 | 
			
		||||
    ipcMain.handle('ollama:start-service', async () => await ollamaService.handleStartService());
 | 
			
		||||
    ipcMain.handle('ollama:install', async (event) => await ollamaService.handleInstall(event));
 | 
			
		||||
    ipcMain.handle('ollama:start-service', async (event) => await ollamaService.handleStartService(event));
 | 
			
		||||
    ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady());
 | 
			
		||||
    ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels());
 | 
			
		||||
    ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions());
 | 
			
		||||
    ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(modelName));
 | 
			
		||||
    ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(event, modelName));
 | 
			
		||||
    ipcMain.handle('ollama:is-model-installed', async (event, modelName) => await ollamaService.handleIsModelInstalled(modelName));
 | 
			
		||||
    ipcMain.handle('ollama:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName));
 | 
			
		||||
    ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp());
 | 
			
		||||
    ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus());
 | 
			
		||||
    ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(force));
 | 
			
		||||
    ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(event, force));
 | 
			
		||||
 | 
			
		||||
    // Ask
 | 
			
		||||
    ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt));
 | 
			
		||||
    ipcMain.handle('ask:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt));
 | 
			
		||||
    ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton());
 | 
			
		||||
    ipcMain.handle('ask:closeAskWindow',  async () => await askService.closeAskWindow());
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    // Listen
 | 
			
		||||
    ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType));
 | 
			
		||||
    ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => {
 | 
			
		||||
@ -96,7 +89,6 @@ module.exports = {
 | 
			
		||||
    ipcMain.handle('listen:startMacosSystemAudio', async () => await listenService.handleStartMacosAudio());
 | 
			
		||||
    ipcMain.handle('listen:stopMacosSystemAudio', async () => await listenService.handleStopMacosAudio());
 | 
			
		||||
    ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled));
 | 
			
		||||
    ipcMain.handle('listen:isSessionActive', async () => await listenService.isSessionActive());
 | 
			
		||||
    ipcMain.handle('listen:changeSession', async (event, listenButtonText) => {
 | 
			
		||||
      console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText);
 | 
			
		||||
      try {
 | 
			
		||||
@ -108,126 +100,20 @@ module.exports = {
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // ModelStateService
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
     // ModelStateService
 | 
			
		||||
    ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
 | 
			
		||||
    ipcMain.handle('model:get-all-keys', async () => await modelStateService.getAllApiKeys());
 | 
			
		||||
    ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys());
 | 
			
		||||
    ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key));
 | 
			
		||||
    ipcMain.handle('model:remove-api-key', async (e, provider) => await modelStateService.handleRemoveApiKey(provider));
 | 
			
		||||
    ipcMain.handle('model:get-selected-models', async () => await modelStateService.getSelectedModels());
 | 
			
		||||
    ipcMain.handle('model:get-selected-models', () => modelStateService.getSelectedModels());
 | 
			
		||||
    ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId));
 | 
			
		||||
    ipcMain.handle('model:get-available-models', async (e, { type }) => await modelStateService.getAvailableModels(type));
 | 
			
		||||
    ipcMain.handle('model:are-providers-configured', async () => await modelStateService.areProvidersConfigured());
 | 
			
		||||
    ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type));
 | 
			
		||||
    ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
 | 
			
		||||
    ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
 | 
			
		||||
    ipcMain.handle('model:re-initialize-state', async () => await 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,31 +1,30 @@
 | 
			
		||||
// src/bridge/windowBridge.js
 | 
			
		||||
const { ipcMain, shell } = require('electron');
 | 
			
		||||
const { ipcMain, BrowserWindow } = 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.on('show-settings-window', () => windowManager.showSettingsWindow());
 | 
			
		||||
    ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor());
 | 
			
		||||
    ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds));
 | 
			
		||||
    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.handle('open-external', (event, url) => shell.openExternal(url));
 | 
			
		||||
    ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));
 | 
			
		||||
 | 
			
		||||
    // Newly moved handlers from windowManager
 | 
			
		||||
    ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state));
 | 
			
		||||
    ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state));
 | 
			
		||||
    ipcMain.handle('get-header-position', () => windowManager.getHeaderPosition());
 | 
			
		||||
    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, { winName, height }) => windowManager.adjustWindowHeight(winName, height));
 | 
			
		||||
    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) {
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,6 @@ const { BrowserWindow } = require('electron');
 | 
			
		||||
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 getWindowPool = () => {
 | 
			
		||||
    try {
 | 
			
		||||
@ -11,6 +10,8 @@ const getWindowPool = () => {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
const updateLayout = () => getWindowManager().updateLayout();
 | 
			
		||||
const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible();
 | 
			
		||||
 | 
			
		||||
const sessionRepository = require('../common/repositories/session');
 | 
			
		||||
const askRepository = require('./repositories');
 | 
			
		||||
@ -144,57 +145,35 @@ class AskService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async toggleAskButton(inputScreenOnly = false) {
 | 
			
		||||
    async toggleAskButton() {
 | 
			
		||||
        const askWindow = getWindowPool()?.get('ask');
 | 
			
		||||
 | 
			
		||||
        let shouldSendScreenOnly = false;
 | 
			
		||||
        if (inputScreenOnly && this.state.showTextInput && askWindow && askWindow.isVisible()) {
 | 
			
		||||
            shouldSendScreenOnly = true;
 | 
			
		||||
            await this.sendMessage('', []);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const hasContent = this.state.isLoading || this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
 | 
			
		||||
        // 답변이 있거나 스트리밍 중일 때
 | 
			
		||||
        const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
 | 
			
		||||
 | 
			
		||||
        if (askWindow && askWindow.isVisible() && hasContent) {
 | 
			
		||||
            // 창을 닫는 대신, 텍스트 입력창만 토글합니다.
 | 
			
		||||
            this.state.showTextInput = !this.state.showTextInput;
 | 
			
		||||
            this._broadcastState();
 | 
			
		||||
            this._broadcastState(); // 변경된 상태 전파
 | 
			
		||||
        } else {
 | 
			
		||||
            // 기존의 창 보이기/숨기기 로직
 | 
			
		||||
            if (askWindow && askWindow.isVisible()) {
 | 
			
		||||
                internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
 | 
			
		||||
                askWindow.webContents.send('window-hide-animation');
 | 
			
		||||
                this.state.isVisible = false;
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log('[AskService] Showing hidden Ask window');
 | 
			
		||||
                internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
 | 
			
		||||
                this.state.isVisible = true;
 | 
			
		||||
                askWindow?.show();
 | 
			
		||||
                updateLayout();
 | 
			
		||||
                askWindow?.webContents.send('window-show-animation');
 | 
			
		||||
            }
 | 
			
		||||
            // 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다.
 | 
			
		||||
            if (this.state.isVisible) {
 | 
			
		||||
                this.state.showTextInput = true;
 | 
			
		||||
                this._broadcastState();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async closeAskWindow () {
 | 
			
		||||
            if (this.abortController) {
 | 
			
		||||
                this.abortController.abort('Window closed by user');
 | 
			
		||||
                this.abortController = null;
 | 
			
		||||
            }
 | 
			
		||||
    
 | 
			
		||||
            this.state = {
 | 
			
		||||
                isVisible      : false,
 | 
			
		||||
                isLoading      : false,
 | 
			
		||||
                isStreaming    : false,
 | 
			
		||||
                currentQuestion: '',
 | 
			
		||||
                currentResponse: '',
 | 
			
		||||
                showTextInput  : true,
 | 
			
		||||
            };
 | 
			
		||||
            this._broadcastState();
 | 
			
		||||
    
 | 
			
		||||
            internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
 | 
			
		||||
    
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -216,16 +195,7 @@ class AskService {
 | 
			
		||||
     * @returns {Promise<{success: boolean, response?: string, error?: string}>}
 | 
			
		||||
     */
 | 
			
		||||
    async sendMessage(userPrompt, conversationHistoryRaw=[]) {
 | 
			
		||||
        internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
 | 
			
		||||
        this.state = {
 | 
			
		||||
            ...this.state,
 | 
			
		||||
            isLoading: true,
 | 
			
		||||
            isStreaming: false,
 | 
			
		||||
            currentQuestion: userPrompt,
 | 
			
		||||
            currentResponse: '',
 | 
			
		||||
            showTextInput: false,
 | 
			
		||||
        };
 | 
			
		||||
        this._broadcastState();
 | 
			
		||||
        ensureAskWindowVisible();
 | 
			
		||||
 | 
			
		||||
        if (this.abortController) {
 | 
			
		||||
            this.abortController.abort('New request received.');
 | 
			
		||||
@ -233,17 +203,31 @@ class AskService {
 | 
			
		||||
        this.abortController = new AbortController();
 | 
			
		||||
        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() });
 | 
			
		||||
            console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
 | 
			
		||||
            
 | 
			
		||||
            const modelInfo = await modelStateService.getCurrentModelInfo('llm');
 | 
			
		||||
            const modelInfo = modelStateService.getCurrentModelInfo('llm');
 | 
			
		||||
            if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
                throw new Error('AI model or API key not configured.');
 | 
			
		||||
            }
 | 
			
		||||
@ -282,78 +266,35 @@ class AskService {
 | 
			
		||||
                portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await streamingLLM.streamChat(messages);
 | 
			
		||||
                const askWin = getWindowPool()?.get('ask');
 | 
			
		||||
            const response = await streamingLLM.streamChat(messages);
 | 
			
		||||
            const askWin = getWindowPool()?.get('ask');
 | 
			
		||||
 | 
			
		||||
                if (!askWin || askWin.isDestroyed()) {
 | 
			
		||||
                    console.error("[AskService] Ask window is not available to send stream to.");
 | 
			
		||||
                    response.body.getReader().cancel();
 | 
			
		||||
                    return { success: false, error: 'Ask window is not available.' };
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const reader = response.body.getReader();
 | 
			
		||||
                signal.addEventListener('abort', () => {
 | 
			
		||||
                    console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`);
 | 
			
		||||
                    reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ });
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                await this._processStream(reader, askWin, sessionId, signal);
 | 
			
		||||
                return { success: true };
 | 
			
		||||
 | 
			
		||||
            } catch (multimodalError) {
 | 
			
		||||
                // 멀티모달 요청이 실패했고 스크린샷이 포함되어 있다면 텍스트만으로 재시도
 | 
			
		||||
                if (screenshotBase64 && this._isMultimodalError(multimodalError)) {
 | 
			
		||||
                    console.log(`[AskService] Multimodal request failed, retrying with text-only: ${multimodalError.message}`);
 | 
			
		||||
                    
 | 
			
		||||
                    // 텍스트만으로 메시지 재구성
 | 
			
		||||
                    const textOnlyMessages = [
 | 
			
		||||
                        { role: 'system', content: systemPrompt },
 | 
			
		||||
                        {
 | 
			
		||||
                            role: 'user',
 | 
			
		||||
                            content: `User Request: ${userPrompt.trim()}`
 | 
			
		||||
                        }
 | 
			
		||||
                    ];
 | 
			
		||||
 | 
			
		||||
                    const fallbackResponse = await streamingLLM.streamChat(textOnlyMessages);
 | 
			
		||||
                    const askWin = getWindowPool()?.get('ask');
 | 
			
		||||
 | 
			
		||||
                    if (!askWin || askWin.isDestroyed()) {
 | 
			
		||||
                        console.error("[AskService] Ask window is not available for fallback response.");
 | 
			
		||||
                        fallbackResponse.body.getReader().cancel();
 | 
			
		||||
                        return { success: false, error: 'Ask window is not available.' };
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const fallbackReader = fallbackResponse.body.getReader();
 | 
			
		||||
                    signal.addEventListener('abort', () => {
 | 
			
		||||
                        console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`);
 | 
			
		||||
                        fallbackReader.cancel(signal.reason).catch(() => {});
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    await this._processStream(fallbackReader, askWin, sessionId, signal);
 | 
			
		||||
                    return { success: true };
 | 
			
		||||
                } else {
 | 
			
		||||
                    // 다른 종류의 에러이거나 스크린샷이 없었다면 그대로 throw
 | 
			
		||||
                    throw multimodalError;
 | 
			
		||||
                }
 | 
			
		||||
            if (!askWin || askWin.isDestroyed()) {
 | 
			
		||||
                console.error("[AskService] Ask window is not available to send stream to.");
 | 
			
		||||
                response.body.getReader().cancel();
 | 
			
		||||
                return { success: false, error: 'Ask window is not available.' };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const reader = response.body.getReader();
 | 
			
		||||
            signal.addEventListener('abort', () => {
 | 
			
		||||
                console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`);
 | 
			
		||||
                reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ });
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            await this._processStream(reader, askWin, sessionId, signal);
 | 
			
		||||
 | 
			
		||||
            return { success: true };
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[AskService] Error during message processing:', error);
 | 
			
		||||
            this.state = {
 | 
			
		||||
                ...this.state,
 | 
			
		||||
                isLoading: false,
 | 
			
		||||
                isStreaming: false,
 | 
			
		||||
                showTextInput: true,
 | 
			
		||||
            };
 | 
			
		||||
            this._broadcastState();
 | 
			
		||||
 | 
			
		||||
            const askWin = getWindowPool()?.get('ask');
 | 
			
		||||
            if (askWin && !askWin.isDestroyed()) {
 | 
			
		||||
                const streamError = error.message || 'Unknown error occurred';
 | 
			
		||||
                askWin.webContents.send('ask-response-stream-error', { error: streamError });
 | 
			
		||||
            if (error.name === 'AbortError') {
 | 
			
		||||
                console.log('[AskService] SendMessage operation was successfully aborted.');
 | 
			
		||||
                return { success: true, response: 'Cancelled' };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            console.error('[AskService] Error processing message:', error);
 | 
			
		||||
            this.state.isLoading = false;
 | 
			
		||||
            this.state.error = error.message;
 | 
			
		||||
            this._broadcastState();
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -425,24 +366,6 @@ class AskService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 멀티모달 관련 에러인지 판단
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    _isMultimodalError(error) {
 | 
			
		||||
        const errorMessage = error.message?.toLowerCase() || '';
 | 
			
		||||
        return (
 | 
			
		||||
            errorMessage.includes('vision') ||
 | 
			
		||||
            errorMessage.includes('image') ||
 | 
			
		||||
            errorMessage.includes('multimodal') ||
 | 
			
		||||
            errorMessage.includes('unsupported') ||
 | 
			
		||||
            errorMessage.includes('image_url') ||
 | 
			
		||||
            errorMessage.includes('400') ||  // Bad Request often for unsupported features
 | 
			
		||||
            errorMessage.includes('invalid') ||
 | 
			
		||||
            errorMessage.includes('not supported')
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const askService = new AskService();
 | 
			
		||||
 | 
			
		||||
@ -57,14 +57,6 @@ 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"),
 | 
			
		||||
@ -76,8 +68,7 @@ const PROVIDERS = {
 | 
			
		||||
      handler: () => {
 | 
			
		||||
          // This needs to remain a function due to its conditional logic for renderer/main process
 | 
			
		||||
          if (typeof window === 'undefined') {
 | 
			
		||||
              const { WhisperProvider } = require("./providers/whisper");
 | 
			
		||||
              return new WhisperProvider();
 | 
			
		||||
              return require("./providers/whisper");
 | 
			
		||||
          }
 | 
			
		||||
          // Return a dummy object for the renderer process
 | 
			
		||||
          return {
 | 
			
		||||
@ -156,7 +147,6 @@ function getProviderClass(providerId) {
 | 
			
		||||
        'openai': 'OpenAIProvider',
 | 
			
		||||
        'anthropic': 'AnthropicProvider',
 | 
			
		||||
        'gemini': 'GeminiProvider',
 | 
			
		||||
        'deepgram': 'DeepgramProvider',
 | 
			
		||||
        'ollama': 'OllamaProvider',
 | 
			
		||||
        'whisper': 'WhisperProvider'
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
@ -1,111 +0,0 @@
 | 
			
		||||
// 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
 | 
			
		||||
};
 | 
			
		||||
@ -1,79 +1,6 @@
 | 
			
		||||
const http = require('http');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
 | 
			
		||||
// Request Queue System for Ollama API (only for non-streaming requests)
 | 
			
		||||
class RequestQueue {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.queue = [];
 | 
			
		||||
        this.processing = false;
 | 
			
		||||
        this.streamingActive = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async addStreamingRequest(requestFn) {
 | 
			
		||||
        // Streaming requests have priority - wait for current processing to finish
 | 
			
		||||
        while (this.processing) {
 | 
			
		||||
            await new Promise(resolve => setTimeout(resolve, 50));
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this.streamingActive = true;
 | 
			
		||||
        console.log('[Ollama Queue] Starting streaming request (priority)');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const result = await requestFn();
 | 
			
		||||
            return result;
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.streamingActive = false;
 | 
			
		||||
            console.log('[Ollama Queue] Streaming request completed');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async add(requestFn) {
 | 
			
		||||
        return new Promise((resolve, reject) => {
 | 
			
		||||
            this.queue.push({ requestFn, resolve, reject });
 | 
			
		||||
            this.process();
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async process() {
 | 
			
		||||
        if (this.processing || this.queue.length === 0) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Wait if streaming is active
 | 
			
		||||
        if (this.streamingActive) {
 | 
			
		||||
            setTimeout(() => this.process(), 100);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.processing = true;
 | 
			
		||||
 | 
			
		||||
        while (this.queue.length > 0) {
 | 
			
		||||
            // Check if streaming started while processing queue
 | 
			
		||||
            if (this.streamingActive) {
 | 
			
		||||
                this.processing = false;
 | 
			
		||||
                setTimeout(() => this.process(), 100);
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const { requestFn, resolve, reject } = this.queue.shift();
 | 
			
		||||
            
 | 
			
		||||
            try {
 | 
			
		||||
                console.log(`[Ollama Queue] Processing queued request (${this.queue.length} remaining)`);
 | 
			
		||||
                const result = await requestFn();
 | 
			
		||||
                resolve(result);
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('[Ollama Queue] Request failed:', error);
 | 
			
		||||
                reject(error);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.processing = false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Global request queue instance
 | 
			
		||||
const requestQueue = new RequestQueue();
 | 
			
		||||
 | 
			
		||||
class OllamaProvider {
 | 
			
		||||
    static async validateApiKey() {
 | 
			
		||||
        try {
 | 
			
		||||
@ -152,77 +79,71 @@ function createLLM({
 | 
			
		||||
            }
 | 
			
		||||
            messages.push({ role: 'user', content: userContent.join('\n') });
 | 
			
		||||
 | 
			
		||||
            // Use request queue to prevent concurrent API calls
 | 
			
		||||
            return await requestQueue.add(async () => {
 | 
			
		||||
                try {
 | 
			
		||||
                    const response = await fetch(`${baseUrl}/api/chat`, {
 | 
			
		||||
                        method: 'POST',
 | 
			
		||||
                        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                        body: JSON.stringify({
 | 
			
		||||
                            model,
 | 
			
		||||
                            messages,
 | 
			
		||||
                            stream: false,
 | 
			
		||||
                            options: {
 | 
			
		||||
                                temperature,
 | 
			
		||||
                                num_predict: maxTokens,
 | 
			
		||||
                            }
 | 
			
		||||
                        })
 | 
			
		||||
                    });
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await fetch(`${baseUrl}/api/chat`, {
 | 
			
		||||
                    method: 'POST',
 | 
			
		||||
                    headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                    body: JSON.stringify({
 | 
			
		||||
                        model,
 | 
			
		||||
                        messages,
 | 
			
		||||
                        stream: false,
 | 
			
		||||
                        options: {
 | 
			
		||||
                            temperature,
 | 
			
		||||
                            num_predict: maxTokens,
 | 
			
		||||
                        }
 | 
			
		||||
                    })
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                    if (!response.ok) {
 | 
			
		||||
                        throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const result = await response.json();
 | 
			
		||||
                    
 | 
			
		||||
                    return {
 | 
			
		||||
                        response: {
 | 
			
		||||
                            text: () => result.message.content
 | 
			
		||||
                        },
 | 
			
		||||
                        raw: result
 | 
			
		||||
                    };
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error('Ollama LLM error:', error);
 | 
			
		||||
                    throw error;
 | 
			
		||||
                if (!response.ok) {
 | 
			
		||||
                    throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
                const result = await response.json();
 | 
			
		||||
                
 | 
			
		||||
                return {
 | 
			
		||||
                    response: {
 | 
			
		||||
                        text: () => result.message.content
 | 
			
		||||
                    },
 | 
			
		||||
                    raw: result
 | 
			
		||||
                };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Ollama LLM error:', error);
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        chat: async (messages) => {
 | 
			
		||||
            const ollamaMessages = convertMessagesToOllamaFormat(messages);
 | 
			
		||||
 | 
			
		||||
            // Use request queue to prevent concurrent API calls
 | 
			
		||||
            return await requestQueue.add(async () => {
 | 
			
		||||
                try {
 | 
			
		||||
                    const response = await fetch(`${baseUrl}/api/chat`, {
 | 
			
		||||
                        method: 'POST',
 | 
			
		||||
                        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                        body: JSON.stringify({
 | 
			
		||||
                            model,
 | 
			
		||||
                            messages: ollamaMessages,
 | 
			
		||||
                            stream: false,
 | 
			
		||||
                            options: {
 | 
			
		||||
                                temperature,
 | 
			
		||||
                                num_predict: maxTokens,
 | 
			
		||||
                            }
 | 
			
		||||
                        })
 | 
			
		||||
                    });
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await fetch(`${baseUrl}/api/chat`, {
 | 
			
		||||
                    method: 'POST',
 | 
			
		||||
                    headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                    body: JSON.stringify({
 | 
			
		||||
                        model,
 | 
			
		||||
                        messages: ollamaMessages,
 | 
			
		||||
                        stream: false,
 | 
			
		||||
                        options: {
 | 
			
		||||
                            temperature,
 | 
			
		||||
                            num_predict: maxTokens,
 | 
			
		||||
                        }
 | 
			
		||||
                    })
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                    if (!response.ok) {
 | 
			
		||||
                        throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    const result = await response.json();
 | 
			
		||||
                    
 | 
			
		||||
                    return {
 | 
			
		||||
                        content: result.message.content,
 | 
			
		||||
                        raw: result
 | 
			
		||||
                    };
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error('Ollama chat error:', error);
 | 
			
		||||
                    throw error;
 | 
			
		||||
                if (!response.ok) {
 | 
			
		||||
                    throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
                const result = await response.json();
 | 
			
		||||
                
 | 
			
		||||
                return {
 | 
			
		||||
                    content: result.message.content,
 | 
			
		||||
                    raw: result
 | 
			
		||||
                };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Ollama chat error:', error);
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@ -244,92 +165,89 @@ function createStreamingLLM({
 | 
			
		||||
            const ollamaMessages = convertMessagesToOllamaFormat(messages);
 | 
			
		||||
            console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
 | 
			
		||||
 | 
			
		||||
            // Streaming requests have priority over queued requests
 | 
			
		||||
            return await requestQueue.addStreamingRequest(async () => {
 | 
			
		||||
                try {
 | 
			
		||||
                    const response = await fetch(`${baseUrl}/api/chat`, {
 | 
			
		||||
                        method: 'POST',
 | 
			
		||||
                        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                        body: JSON.stringify({
 | 
			
		||||
                            model,
 | 
			
		||||
                            messages: ollamaMessages,
 | 
			
		||||
                            stream: true,
 | 
			
		||||
                            options: {
 | 
			
		||||
                                temperature,
 | 
			
		||||
                                num_predict: maxTokens,
 | 
			
		||||
                            }
 | 
			
		||||
                        })
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    if (!response.ok) {
 | 
			
		||||
                        throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                    }
 | 
			
		||||
 | 
			
		||||
                    console.log('[Ollama Provider] Got streaming response');
 | 
			
		||||
 | 
			
		||||
                    const stream = new ReadableStream({
 | 
			
		||||
                        async start(controller) {
 | 
			
		||||
                            let buffer = '';
 | 
			
		||||
 | 
			
		||||
                            try {
 | 
			
		||||
                                response.body.on('data', (chunk) => {
 | 
			
		||||
                                    buffer += chunk.toString();
 | 
			
		||||
                                    const lines = buffer.split('\n');
 | 
			
		||||
                                    buffer = lines.pop() || '';
 | 
			
		||||
 | 
			
		||||
                                    for (const line of lines) {
 | 
			
		||||
                                        if (line.trim() === '') continue;
 | 
			
		||||
                                        
 | 
			
		||||
                                        try {
 | 
			
		||||
                                            const data = JSON.parse(line);
 | 
			
		||||
                                            
 | 
			
		||||
                                            if (data.message?.content) {
 | 
			
		||||
                                                const sseData = JSON.stringify({
 | 
			
		||||
                                                    choices: [{
 | 
			
		||||
                                                        delta: {
 | 
			
		||||
                                                            content: data.message.content
 | 
			
		||||
                                                        }
 | 
			
		||||
                                                    }]
 | 
			
		||||
                                                });
 | 
			
		||||
                                                controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
 | 
			
		||||
                                            }
 | 
			
		||||
                                            
 | 
			
		||||
                                            if (data.done) {
 | 
			
		||||
                                                controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
 | 
			
		||||
                                            }
 | 
			
		||||
                                        } catch (e) {
 | 
			
		||||
                                            console.error('[Ollama Provider] Failed to parse chunk:', e);
 | 
			
		||||
                                        }
 | 
			
		||||
                                    }
 | 
			
		||||
                                });
 | 
			
		||||
 | 
			
		||||
                                response.body.on('end', () => {
 | 
			
		||||
                                    controller.close();
 | 
			
		||||
                                    console.log('[Ollama Provider] Streaming completed');
 | 
			
		||||
                                });
 | 
			
		||||
 | 
			
		||||
                                response.body.on('error', (error) => {
 | 
			
		||||
                                    console.error('[Ollama Provider] Streaming error:', error);
 | 
			
		||||
                                    controller.error(error);
 | 
			
		||||
                                });
 | 
			
		||||
                                
 | 
			
		||||
                            } catch (error) {
 | 
			
		||||
                                console.error('[Ollama Provider] Streaming setup error:', error);
 | 
			
		||||
                                controller.error(error);
 | 
			
		||||
                            }
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await fetch(`${baseUrl}/api/chat`, {
 | 
			
		||||
                    method: 'POST',
 | 
			
		||||
                    headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                    body: JSON.stringify({
 | 
			
		||||
                        model,
 | 
			
		||||
                        messages: ollamaMessages,
 | 
			
		||||
                        stream: true,
 | 
			
		||||
                        options: {
 | 
			
		||||
                            temperature,
 | 
			
		||||
                            num_predict: maxTokens,
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
                    })
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                    return {
 | 
			
		||||
                        ok: true,
 | 
			
		||||
                        body: stream
 | 
			
		||||
                    };
 | 
			
		||||
                    
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error('[Ollama Provider] Request error:', error);
 | 
			
		||||
                    throw error;
 | 
			
		||||
                if (!response.ok) {
 | 
			
		||||
                    throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
                console.log('[Ollama Provider] Got streaming response');
 | 
			
		||||
 | 
			
		||||
                const stream = new ReadableStream({
 | 
			
		||||
                    async start(controller) {
 | 
			
		||||
                        let buffer = '';
 | 
			
		||||
 | 
			
		||||
                        try {
 | 
			
		||||
                            response.body.on('data', (chunk) => {
 | 
			
		||||
                                buffer += chunk.toString();
 | 
			
		||||
                                const lines = buffer.split('\n');
 | 
			
		||||
                                buffer = lines.pop() || '';
 | 
			
		||||
 | 
			
		||||
                                for (const line of lines) {
 | 
			
		||||
                                    if (line.trim() === '') continue;
 | 
			
		||||
                                    
 | 
			
		||||
                                    try {
 | 
			
		||||
                                        const data = JSON.parse(line);
 | 
			
		||||
                                        
 | 
			
		||||
                                        if (data.message?.content) {
 | 
			
		||||
                                            const sseData = JSON.stringify({
 | 
			
		||||
                                                choices: [{
 | 
			
		||||
                                                    delta: {
 | 
			
		||||
                                                        content: data.message.content
 | 
			
		||||
                                                    }
 | 
			
		||||
                                                }]
 | 
			
		||||
                                            });
 | 
			
		||||
                                            controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
 | 
			
		||||
                                        }
 | 
			
		||||
                                        
 | 
			
		||||
                                        if (data.done) {
 | 
			
		||||
                                            controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
 | 
			
		||||
                                        }
 | 
			
		||||
                                    } catch (e) {
 | 
			
		||||
                                        console.error('[Ollama Provider] Failed to parse chunk:', e);
 | 
			
		||||
                                    }
 | 
			
		||||
                                }
 | 
			
		||||
                            });
 | 
			
		||||
 | 
			
		||||
                            response.body.on('end', () => {
 | 
			
		||||
                                controller.close();
 | 
			
		||||
                                console.log('[Ollama Provider] Streaming completed');
 | 
			
		||||
                            });
 | 
			
		||||
 | 
			
		||||
                            response.body.on('error', (error) => {
 | 
			
		||||
                                console.error('[Ollama Provider] Streaming error:', error);
 | 
			
		||||
                                controller.error(error);
 | 
			
		||||
                            });
 | 
			
		||||
                            
 | 
			
		||||
                        } catch (error) {
 | 
			
		||||
                            console.error('[Ollama Provider] Streaming setup error:', error);
 | 
			
		||||
                            controller.error(error);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                return {
 | 
			
		||||
                    ok: true,
 | 
			
		||||
                    body: stream
 | 
			
		||||
                };
 | 
			
		||||
                
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('[Ollama Provider] Request error:', error);
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -41,7 +41,7 @@ class WhisperSTTSession extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
    startProcessingLoop() {
 | 
			
		||||
        this.processingInterval = setInterval(async () => {
 | 
			
		||||
            const minBufferSize = 16000 * 2 * 0.15;
 | 
			
		||||
            const minBufferSize = 24000 * 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();
 | 
			
		||||
@ -184,10 +184,9 @@ class WhisperProvider {
 | 
			
		||||
 | 
			
		||||
    async initialize() {
 | 
			
		||||
        if (!this.whisperService) {
 | 
			
		||||
            this.whisperService = require('../../services/whisperService');
 | 
			
		||||
            if (!this.whisperService.isInitialized) {
 | 
			
		||||
                await this.whisperService.initialize();
 | 
			
		||||
            }
 | 
			
		||||
            const { WhisperService } = require('../../services/whisperService');
 | 
			
		||||
            this.whisperService = new WhisperService();
 | 
			
		||||
            await this.whisperService.initialize();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,49 +2,41 @@ const DOWNLOAD_CHECKSUMS = {
 | 
			
		||||
    ollama: {
 | 
			
		||||
        dmg: {
 | 
			
		||||
            url: 'https://ollama.com/download/Ollama.dmg',
 | 
			
		||||
            sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
 | 
			
		||||
            sha256: null // To be updated with actual checksum
 | 
			
		||||
        },
 | 
			
		||||
        exe: {
 | 
			
		||||
            url: 'https://ollama.com/download/OllamaSetup.exe',
 | 
			
		||||
            sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
 | 
			
		||||
        },
 | 
			
		||||
        linux: {
 | 
			
		||||
            url: 'curl -fsSL https://ollama.com/install.sh | sh',
 | 
			
		||||
            sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
 | 
			
		||||
            sha256: null // To be updated with actual checksum
 | 
			
		||||
        }
 | 
			
		||||
    },
 | 
			
		||||
    whisper: {
 | 
			
		||||
        models: {
 | 
			
		||||
            'whisper-tiny': {
 | 
			
		||||
                url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-tiny.bin',
 | 
			
		||||
                url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
 | 
			
		||||
                sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
 | 
			
		||||
            },
 | 
			
		||||
            'whisper-base': {
 | 
			
		||||
                url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-base.bin',
 | 
			
		||||
                url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
 | 
			
		||||
                sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
 | 
			
		||||
            },
 | 
			
		||||
            'whisper-small': {
 | 
			
		||||
                url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-small.bin',
 | 
			
		||||
                url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
 | 
			
		||||
                sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
 | 
			
		||||
            },
 | 
			
		||||
            'whisper-medium': {
 | 
			
		||||
                url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-medium.bin',
 | 
			
		||||
                url: 'https://huggingface.co/ggerganov/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/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
 | 
			
		||||
                    sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
 | 
			
		||||
                    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
 | 
			
		||||
                },
 | 
			
		||||
                linux: {
 | 
			
		||||
                    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일 경우 체크섬 검증 스킵됨
 | 
			
		||||
                    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
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -91,16 +91,25 @@ const LATEST_SCHEMA = {
 | 
			
		||||
    },
 | 
			
		||||
    provider_settings: {
 | 
			
		||||
        columns: [
 | 
			
		||||
            { name: 'uid', type: 'TEXT NOT NULL' },
 | 
			
		||||
            { name: 'provider', type: 'TEXT NOT NULL' },
 | 
			
		||||
            { 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 (provider)']
 | 
			
		||||
        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: [
 | 
			
		||||
@ -108,12 +117,6 @@ const LATEST_SCHEMA = {
 | 
			
		||||
            { name: 'accelerator', type: 'TEXT NOT NULL' },
 | 
			
		||||
            { name: 'created_at', type: 'INTEGER' }
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    permissions: {
 | 
			
		||||
        columns: [
 | 
			
		||||
            { name: 'uid', type: 'TEXT PRIMARY KEY' },
 | 
			
		||||
            { name: 'keychain_completed', type: 'INTEGER DEFAULT 0' }
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -33,13 +33,7 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
 | 
			
		||||
 | 
			
		||||
            for (const field of fieldsToEncrypt) {
 | 
			
		||||
                 if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) {
 | 
			
		||||
                    try {
 | 
			
		||||
                        appObject[field] = encryptionService.decrypt(appObject[field]);
 | 
			
		||||
                    } catch (error) {
 | 
			
		||||
                        console.warn(`[FirestoreConverter] Failed to decrypt field '${field}' (possibly plaintext or key mismatch):`, error.message);
 | 
			
		||||
                        // Keep the original value instead of failing
 | 
			
		||||
                        // appObject[field] remains as is
 | 
			
		||||
                    }
 | 
			
		||||
                    appObject[field] = encryptionService.decrypt(appObject[field]);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,6 +6,6 @@ function getRepository() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args),
 | 
			
		||||
    checkKeychainCompleted: (...args) => getRepository().checkKeychainCompleted(...args),
 | 
			
		||||
    markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args),
 | 
			
		||||
    checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...args),
 | 
			
		||||
}; 
 | 
			
		||||
@ -1,18 +1,14 @@
 | 
			
		||||
const sqliteClient = require('../../services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function markKeychainCompleted(uid) {
 | 
			
		||||
    return sqliteClient.query(
 | 
			
		||||
        'INSERT OR REPLACE INTO permissions (uid, keychain_completed) VALUES (?, 1)',
 | 
			
		||||
        [uid]
 | 
			
		||||
    );
 | 
			
		||||
async function markPermissionsAsCompleted() {
 | 
			
		||||
    return sqliteClient.markPermissionsAsCompleted();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function checkKeychainCompleted(uid) {
 | 
			
		||||
    const row = sqliteClient.query('SELECT keychain_completed FROM permissions WHERE uid = ?', [uid]);
 | 
			
		||||
    return row.length > 0 && row[0].keychain_completed === 1;
 | 
			
		||||
async function checkPermissionsCompleted() {
 | 
			
		||||
    return sqliteClient.checkPermissionsCompleted();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    markKeychainCompleted,
 | 
			
		||||
    checkKeychainCompleted
 | 
			
		||||
    markPermissionsAsCompleted,
 | 
			
		||||
    checkPermissionsCompleted,
 | 
			
		||||
}; 
 | 
			
		||||
@ -0,0 +1,83 @@
 | 
			
		||||
const { collection, doc, getDoc, getDocs, setDoc, deleteDoc, query, where } = require('firebase/firestore');
 | 
			
		||||
const { getFirestoreInstance: getFirestore } = require('../../services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../firestoreConverter');
 | 
			
		||||
 | 
			
		||||
// Create encrypted converter for provider settings
 | 
			
		||||
const providerSettingsConverter = createEncryptedConverter([
 | 
			
		||||
    'api_key', // Encrypt API keys
 | 
			
		||||
    'selected_llm_model', // Encrypt model selections for privacy
 | 
			
		||||
    'selected_stt_model'
 | 
			
		||||
]);
 | 
			
		||||
 | 
			
		||||
function providerSettingsCol() {
 | 
			
		||||
    const db = getFirestore();
 | 
			
		||||
    return collection(db, 'provider_settings').withConverter(providerSettingsConverter);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getByProvider(uid, provider) {
 | 
			
		||||
    try {
 | 
			
		||||
        const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
 | 
			
		||||
        const docSnap = await getDoc(docRef);
 | 
			
		||||
        return docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[ProviderSettings Firebase] Error getting provider settings:', error);
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function getAllByUid(uid) {
 | 
			
		||||
    try {
 | 
			
		||||
        const q = query(providerSettingsCol(), where('uid', '==', uid));
 | 
			
		||||
        const querySnapshot = await getDocs(q);
 | 
			
		||||
        return querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[ProviderSettings Firebase] Error getting all provider settings:', error);
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function upsert(uid, provider, settings) {
 | 
			
		||||
    try {
 | 
			
		||||
        const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
 | 
			
		||||
        await setDoc(docRef, settings, { merge: true });
 | 
			
		||||
        return { changes: 1 };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[ProviderSettings Firebase] Error upserting provider settings:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function remove(uid, provider) {
 | 
			
		||||
    try {
 | 
			
		||||
        const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
 | 
			
		||||
        await deleteDoc(docRef);
 | 
			
		||||
        return { changes: 1 };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[ProviderSettings Firebase] Error removing provider settings:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function removeAllByUid(uid) {
 | 
			
		||||
    try {
 | 
			
		||||
        const settings = await getAllByUid(uid);
 | 
			
		||||
        const deletePromises = settings.map(setting => {
 | 
			
		||||
            const docRef = doc(providerSettingsCol(), setting.id);
 | 
			
		||||
            return deleteDoc(docRef);
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        await Promise.all(deletePromises);
 | 
			
		||||
        return { changes: settings.length };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[ProviderSettings Firebase] Error removing all provider settings:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    getByProvider,
 | 
			
		||||
    getAllByUid,
 | 
			
		||||
    upsert,
 | 
			
		||||
    remove,
 | 
			
		||||
    removeAllByUid
 | 
			
		||||
}; 
 | 
			
		||||
@ -1,68 +1,65 @@
 | 
			
		||||
const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
const sqliteRepository = require('./sqlite.repository');
 | 
			
		||||
 | 
			
		||||
let authService = null;
 | 
			
		||||
 | 
			
		||||
function setAuthService(service) {
 | 
			
		||||
    authService = service;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    // For now, we only have sqlite. This could be expanded later.
 | 
			
		||||
    return sqliteRepository;
 | 
			
		||||
    if (!authService) {
 | 
			
		||||
        throw new Error('AuthService not set for providerSettings repository');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
    return user.isLoggedIn ? firebaseRepository : sqliteRepository;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const providerSettingsRepositoryAdapter = {
 | 
			
		||||
    // Core CRUD operations
 | 
			
		||||
    async getByProvider(provider) {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.getByProvider(provider);
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return await repo.getByProvider(uid, provider);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async getAll() {
 | 
			
		||||
    async getAllByUid() {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.getAll();
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return await repo.getAllByUid(uid);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async upsert(provider, settings) {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        const now = Date.now();
 | 
			
		||||
        
 | 
			
		||||
        const settingsWithMeta = {
 | 
			
		||||
            ...settings,
 | 
			
		||||
            uid,
 | 
			
		||||
            provider,
 | 
			
		||||
            updated_at: now,
 | 
			
		||||
            created_at: settings.created_at || now
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        return await repo.upsert(provider, settingsWithMeta);
 | 
			
		||||
        return await repo.upsert(uid, provider, settingsWithMeta);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async remove(provider) {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.remove(provider);
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return await repo.remove(uid, provider);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async removeAll() {
 | 
			
		||||
    async removeAllByUid() {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.removeAll();
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    async getRawApiKeys() {
 | 
			
		||||
        // This function should always target the local sqlite DB,
 | 
			
		||||
        // as it's part of the local-first boot sequence.
 | 
			
		||||
        return await sqliteRepository.getRawApiKeys();
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    async getActiveProvider(type) {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.getActiveProvider(type);
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    async setActiveProvider(provider, type) {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.setActiveProvider(provider, type);
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    async getActiveSettings() {
 | 
			
		||||
        const repo = getBaseRepository();
 | 
			
		||||
        return await repo.getActiveSettings();
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return await repo.removeAllByUid(uid);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    ...providerSettingsRepositoryAdapter
 | 
			
		||||
    ...providerSettingsRepositoryAdapter,
 | 
			
		||||
    setAuthService
 | 
			
		||||
}; 
 | 
			
		||||
@ -1,59 +1,37 @@
 | 
			
		||||
const sqliteClient = require('../../services/sqliteClient');
 | 
			
		||||
const encryptionService = require('../../services/encryptionService');
 | 
			
		||||
 | 
			
		||||
function getByProvider(provider) {
 | 
			
		||||
function getByProvider(uid, provider) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?');
 | 
			
		||||
    const result = stmt.get(provider) || null;
 | 
			
		||||
    
 | 
			
		||||
    if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
 | 
			
		||||
        result.api_key = encryptionService.decrypt(result.api_key);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return result;
 | 
			
		||||
    const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
 | 
			
		||||
    return stmt.get(uid, provider) || null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getAll() {
 | 
			
		||||
function getAllByUid(uid) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider');
 | 
			
		||||
    const results = stmt.all();
 | 
			
		||||
    
 | 
			
		||||
    return results.map(result => {
 | 
			
		||||
        if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
 | 
			
		||||
            result.api_key = encryptionService.decrypt(result.api_key);
 | 
			
		||||
        }
 | 
			
		||||
        return result;
 | 
			
		||||
    });
 | 
			
		||||
    const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
 | 
			
		||||
    return stmt.all(uid);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function upsert(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.');
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
function upsert(uid, provider, settings) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    
 | 
			
		||||
    // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
 | 
			
		||||
    const stmt = db.prepare(`
 | 
			
		||||
        INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
 | 
			
		||||
        VALUES (?, ?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
        ON CONFLICT(provider) DO UPDATE SET
 | 
			
		||||
        INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at)
 | 
			
		||||
        VALUES (?, ?, ?, ?, ?, ?, ?)
 | 
			
		||||
        ON CONFLICT(uid, provider) DO UPDATE SET
 | 
			
		||||
            api_key = excluded.api_key,
 | 
			
		||||
            selected_llm_model = excluded.selected_llm_model,
 | 
			
		||||
            selected_stt_model = excluded.selected_stt_model,
 | 
			
		||||
            -- is_active_llm and is_active_stt are NOT updated here
 | 
			
		||||
            -- Use setActiveProvider() to change active status
 | 
			
		||||
            updated_at = excluded.updated_at
 | 
			
		||||
    `);
 | 
			
		||||
    
 | 
			
		||||
    const result = stmt.run(
 | 
			
		||||
        uid,
 | 
			
		||||
        provider,
 | 
			
		||||
        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
 | 
			
		||||
    );
 | 
			
		||||
@ -61,100 +39,24 @@ function upsert(provider, settings) {
 | 
			
		||||
    return { changes: result.changes };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function remove(provider) {
 | 
			
		||||
function remove(uid, provider) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?');
 | 
			
		||||
    const result = stmt.run(provider);
 | 
			
		||||
    const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?');
 | 
			
		||||
    const result = stmt.run(uid, provider);
 | 
			
		||||
    return { changes: result.changes };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function removeAll() {
 | 
			
		||||
function removeAllByUid(uid) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('DELETE FROM provider_settings');
 | 
			
		||||
    const result = stmt.run();
 | 
			
		||||
    const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?');
 | 
			
		||||
    const result = stmt.run(uid);
 | 
			
		||||
    return { changes: result.changes };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getRawApiKeys() {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('SELECT api_key FROM provider_settings');
 | 
			
		||||
    return stmt.all();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get active provider for a specific type (llm or stt)
 | 
			
		||||
function getActiveProvider(type) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
 | 
			
		||||
    const stmt = db.prepare(`SELECT * FROM provider_settings WHERE ${column} = 1`);
 | 
			
		||||
    const result = stmt.get() || null;
 | 
			
		||||
    
 | 
			
		||||
    if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
 | 
			
		||||
        result.api_key = encryptionService.decrypt(result.api_key);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Set active provider for a specific type
 | 
			
		||||
function setActiveProvider(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`);
 | 
			
		||||
        deactivateStmt.run();
 | 
			
		||||
        
 | 
			
		||||
        // Then activate the specified provider
 | 
			
		||||
        if (provider) {
 | 
			
		||||
            const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE provider = ?`);
 | 
			
		||||
            activateStmt.run(provider);
 | 
			
		||||
        }
 | 
			
		||||
    })();
 | 
			
		||||
    
 | 
			
		||||
    return { success: true };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Get all active settings (both llm and stt)
 | 
			
		||||
function getActiveSettings() {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare(`
 | 
			
		||||
        SELECT * FROM provider_settings 
 | 
			
		||||
        WHERE (is_active_llm = 1 OR is_active_stt = 1)
 | 
			
		||||
        ORDER BY provider
 | 
			
		||||
    `);
 | 
			
		||||
    const results = stmt.all();
 | 
			
		||||
    
 | 
			
		||||
    // Decrypt API keys and organize by type
 | 
			
		||||
    const activeSettings = {
 | 
			
		||||
        llm: null,
 | 
			
		||||
        stt: null
 | 
			
		||||
    };
 | 
			
		||||
    
 | 
			
		||||
    results.forEach(result => {
 | 
			
		||||
        if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
 | 
			
		||||
            result.api_key = encryptionService.decrypt(result.api_key);
 | 
			
		||||
        }
 | 
			
		||||
        if (result.is_active_llm) {
 | 
			
		||||
            activeSettings.llm = result;
 | 
			
		||||
        }
 | 
			
		||||
        if (result.is_active_stt) {
 | 
			
		||||
            activeSettings.stt = result;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    return activeSettings;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    getByProvider,
 | 
			
		||||
    getAll,
 | 
			
		||||
    getAllByUid,
 | 
			
		||||
    upsert,
 | 
			
		||||
    remove,
 | 
			
		||||
    removeAll,
 | 
			
		||||
    getRawApiKeys,
 | 
			
		||||
    getActiveProvider,
 | 
			
		||||
    setActiveProvider,
 | 
			
		||||
    getActiveSettings
 | 
			
		||||
    removeAllByUid
 | 
			
		||||
}; 
 | 
			
		||||
@ -3,19 +3,15 @@ const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
 | 
			
		||||
let authService = null;
 | 
			
		||||
 | 
			
		||||
function getAuthService() {
 | 
			
		||||
    if (!authService) {
 | 
			
		||||
        authService = require('../../services/authService');
 | 
			
		||||
    }
 | 
			
		||||
    return authService;
 | 
			
		||||
function setAuthService(service) {
 | 
			
		||||
    authService = service;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    const service = getAuthService();
 | 
			
		||||
    if (!service) {
 | 
			
		||||
        throw new Error('AuthService could not be loaded for the user repository.');
 | 
			
		||||
    if (!authService) {
 | 
			
		||||
        throw new Error('AuthService has not been set for the user repository.');
 | 
			
		||||
    }
 | 
			
		||||
    const user = service.getCurrentUser();
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
    if (user && user.isLoggedIn) {
 | 
			
		||||
        return firebaseRepository;
 | 
			
		||||
    }
 | 
			
		||||
@ -29,23 +25,24 @@ const userRepositoryAdapter = {
 | 
			
		||||
    },
 | 
			
		||||
    
 | 
			
		||||
    getById: () => {
 | 
			
		||||
        const uid = getAuthService().getCurrentUserId();
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return getBaseRepository().getById(uid);
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    update: (updateData) => {
 | 
			
		||||
        const uid = getAuthService().getCurrentUserId();
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return getBaseRepository().update({ uid, ...updateData });
 | 
			
		||||
    },
 | 
			
		||||
 | 
			
		||||
    deleteById: () => {
 | 
			
		||||
        const uid = getAuthService().getCurrentUserId();
 | 
			
		||||
        const uid = authService.getCurrentUserId();
 | 
			
		||||
        return getBaseRepository().deleteById(uid);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    ...userRepositoryAdapter
 | 
			
		||||
    ...userRepositoryAdapter,
 | 
			
		||||
    setAuthService
 | 
			
		||||
}; 
 | 
			
		||||
@ -0,0 +1,55 @@
 | 
			
		||||
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
 | 
			
		||||
}; 
 | 
			
		||||
@ -0,0 +1,50 @@
 | 
			
		||||
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
 | 
			
		||||
}; 
 | 
			
		||||
@ -0,0 +1,48 @@
 | 
			
		||||
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,7 @@ const encryptionService = require('./encryptionService');
 | 
			
		||||
const migrationService = require('./migrationService');
 | 
			
		||||
const sessionRepository = require('../repositories/session');
 | 
			
		||||
const providerSettingsRepository = require('../repositories/providerSettings');
 | 
			
		||||
const permissionService = require('./permissionService');
 | 
			
		||||
const userModelSelectionsRepository = require('../repositories/userModelSelections');
 | 
			
		||||
 | 
			
		||||
async function getVirtualKeyByEmail(email, idToken) {
 | 
			
		||||
    if (!idToken) {
 | 
			
		||||
@ -43,14 +43,23 @@ class AuthService {
 | 
			
		||||
        this.isInitialized = false;
 | 
			
		||||
 | 
			
		||||
        // This ensures the key is ready before any login/logout state change.
 | 
			
		||||
        encryptionService.initializeKey(this.currentUserId);
 | 
			
		||||
        this.initializationPromise = null;
 | 
			
		||||
 | 
			
		||||
        sessionRepository.setAuthService(this);
 | 
			
		||||
        providerSettingsRepository.setAuthService(this);
 | 
			
		||||
        userModelSelectionsRepository.setAuthService(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize() {
 | 
			
		||||
        if (this.isInitialized) return this.initializationPromise;
 | 
			
		||||
 | 
			
		||||
        // --- Break the circular dependency ---
 | 
			
		||||
        // Inject this authService instance into the session repository so it can be used
 | 
			
		||||
        // without a direct `require` cycle.
 | 
			
		||||
        sessionRepository.setAuthService(this);
 | 
			
		||||
        // --- End of dependency injection ---
 | 
			
		||||
 | 
			
		||||
        this.initializationPromise = new Promise((resolve) => {
 | 
			
		||||
            const auth = getFirebaseAuth();
 | 
			
		||||
            onAuthStateChanged(auth, async (user) => {
 | 
			
		||||
@ -66,32 +75,29 @@ class AuthService {
 | 
			
		||||
                    // Clean up any zombie sessions from a previous run for this user.
 | 
			
		||||
                    await sessionRepository.endAllActiveSessions();
 | 
			
		||||
 | 
			
		||||
                    // ** Initialize encryption key for the logged-in user if permissions are already granted **
 | 
			
		||||
                    if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) {
 | 
			
		||||
                        console.warn('[AuthService] Keychain permission not yet completed for this user. Deferring key initialization.');
 | 
			
		||||
                    } else {
 | 
			
		||||
                        await encryptionService.initializeKey(user.uid);
 | 
			
		||||
                    }
 | 
			
		||||
                    // ** Initialize encryption key for the logged-in user **
 | 
			
		||||
                    await encryptionService.initializeKey(user.uid);
 | 
			
		||||
 | 
			
		||||
                    // ** Check for and run data migration for the user **
 | 
			
		||||
                    // No 'await' here, so it runs in the background without blocking startup.
 | 
			
		||||
                    migrationService.checkAndRunMigration(user);
 | 
			
		||||
 | 
			
		||||
                    // ***** CRITICAL: Wait for the virtual key and model state update to complete *****
 | 
			
		||||
                    try {
 | 
			
		||||
                        const idToken = await user.getIdToken(true);
 | 
			
		||||
                        const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
 | 
			
		||||
 | 
			
		||||
                        if (global.modelStateService) {
 | 
			
		||||
                            // The model state service now writes directly to the DB, no in-memory state.
 | 
			
		||||
                            await global.modelStateService.setFirebaseVirtualKey(virtualKey);
 | 
			
		||||
                    // Start background task to fetch and save virtual key
 | 
			
		||||
                    (async () => {
 | 
			
		||||
                        try {
 | 
			
		||||
                            const idToken = await user.getIdToken(true);
 | 
			
		||||
                            const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
 | 
			
		||||
 | 
			
		||||
                            if (global.modelStateService) {
 | 
			
		||||
                                global.modelStateService.setFirebaseVirtualKey(virtualKey);
 | 
			
		||||
                            }
 | 
			
		||||
                            console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
 | 
			
		||||
 | 
			
		||||
                        } catch (error) {
 | 
			
		||||
                            console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
 | 
			
		||||
                        }
 | 
			
		||||
                        console.log(`[AuthService] Virtual key for ${user.email} has been processed and state updated.`);
 | 
			
		||||
 | 
			
		||||
                    } catch (error) {
 | 
			
		||||
                        console.error('[AuthService] Failed to fetch or save virtual key:', error);
 | 
			
		||||
                        // This is not critical enough to halt the login, but we should log it.
 | 
			
		||||
                    }
 | 
			
		||||
                    })();
 | 
			
		||||
 | 
			
		||||
                } else {
 | 
			
		||||
                    // User signed OUT
 | 
			
		||||
@ -99,8 +105,7 @@ class AuthService {
 | 
			
		||||
                    if (previousUser) {
 | 
			
		||||
                        console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
 | 
			
		||||
                        if (global.modelStateService) {
 | 
			
		||||
                            // The model state service now writes directly to the DB.
 | 
			
		||||
                            await global.modelStateService.setFirebaseVirtualKey(null);
 | 
			
		||||
                            global.modelStateService.setFirebaseVirtualKey(null);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    this.currentUser = null;
 | 
			
		||||
@ -110,7 +115,8 @@ class AuthService {
 | 
			
		||||
                    // End active sessions for the local/default user as well.
 | 
			
		||||
                    await sessionRepository.endAllActiveSessions();
 | 
			
		||||
 | 
			
		||||
                    encryptionService.resetSessionKey();
 | 
			
		||||
                    // ** Initialize encryption key for the default/local user **
 | 
			
		||||
                    await encryptionService.initializeKey(this.currentUserId);
 | 
			
		||||
                }
 | 
			
		||||
                this.broadcastUserState();
 | 
			
		||||
                
 | 
			
		||||
@ -175,6 +181,7 @@ class AuthService {
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    getCurrentUserId() {
 | 
			
		||||
        return this.currentUserId;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -10,13 +10,10 @@ class DatabaseInitializer {
 | 
			
		||||
        
 | 
			
		||||
        // 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
 | 
			
		||||
        const userDataPath = app.getPath('userData');
 | 
			
		||||
        // In both development and production mode, the database is stored in the userData directory:
 | 
			
		||||
        //   macOS: ~/Library/Application Support/Glass/pickleglass.db
 | 
			
		||||
        //   Windows: %APPDATA%\Glass\pickleglass.db
 | 
			
		||||
        this.dbPath = path.join(userDataPath, 'pickleglass.db');
 | 
			
		||||
        this.dataDir = userDataPath;
 | 
			
		||||
 | 
			
		||||
        // The original DB path (read-only location in the package)
 | 
			
		||||
        // 원본 DB 경로 (패키지 내 읽기 전용 위치)
 | 
			
		||||
        this.sourceDbPath = app.isPackaged
 | 
			
		||||
            ? path.join(process.resourcesPath, 'data', 'pickleglass.db')
 | 
			
		||||
            : path.join(app.getAppPath(), 'data', 'pickleglass.db');
 | 
			
		||||
@ -55,7 +52,7 @@ class DatabaseInitializer {
 | 
			
		||||
        try {
 | 
			
		||||
            this.ensureDatabaseExists();
 | 
			
		||||
 | 
			
		||||
            sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
 | 
			
		||||
            await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
 | 
			
		||||
            
 | 
			
		||||
            // This single call will now synchronize the schema and then init default data.
 | 
			
		||||
            await sqliteClient.initTables();
 | 
			
		||||
 | 
			
		||||
@ -9,8 +9,6 @@ try {
 | 
			
		||||
    keytar = null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const permissionService = require('./permissionService');
 | 
			
		||||
 | 
			
		||||
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
 | 
			
		||||
let sessionKey = null; // In-memory fallback key
 | 
			
		||||
 | 
			
		||||
@ -33,8 +31,6 @@ async function initializeKey(userId) {
 | 
			
		||||
        throw new Error('A user ID must be provided to initialize the encryption key.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let keyRetrieved = false;
 | 
			
		||||
 | 
			
		||||
    if (keytar) {
 | 
			
		||||
        try {
 | 
			
		||||
            let key = await keytar.getPassword(SERVICE_NAME, userId);
 | 
			
		||||
@ -45,7 +41,6 @@ async function initializeKey(userId) {
 | 
			
		||||
                console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
 | 
			
		||||
                keyRetrieved = true;
 | 
			
		||||
            }
 | 
			
		||||
            sessionKey = key;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
@ -60,26 +55,12 @@ async function initializeKey(userId) {
 | 
			
		||||
            sessionKey = crypto.randomBytes(32).toString('hex');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Mark keychain completed in permissions DB if this is the first successful retrieval or storage
 | 
			
		||||
    try {
 | 
			
		||||
        await permissionService.markKeychainCompleted(userId);
 | 
			
		||||
        if (keyRetrieved) {
 | 
			
		||||
            console.log(`[EncryptionService] Keychain completion marked in DB for ${userId}.`);
 | 
			
		||||
        }
 | 
			
		||||
    } catch (permErr) {
 | 
			
		||||
        console.error('[EncryptionService] Failed to mark keychain completion:', permErr);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
    if (!sessionKey) {
 | 
			
		||||
        throw new Error('Failed to initialize encryption key.');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function resetSessionKey() {
 | 
			
		||||
    sessionKey = null;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * Encrypts a given text using AES-256-GCM.
 | 
			
		||||
 * @param {string} text The text to encrypt.
 | 
			
		||||
@ -148,28 +129,12 @@ function decrypt(encryptedText) {
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        // It's common for this to fail if the data is not encrypted (e.g., legacy data).
 | 
			
		||||
        // In that case, we return the original value.
 | 
			
		||||
        console.error('[EncryptionService] Decryption failed:', error);
 | 
			
		||||
        return encryptedText;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function looksEncrypted(str) {
 | 
			
		||||
    if (!str || typeof str !== 'string') return false;
 | 
			
		||||
    // Base64 chars + optional '=' padding
 | 
			
		||||
    if (!/^[A-Za-z0-9+/]+={0,2}$/.test(str)) return false;
 | 
			
		||||
    try {
 | 
			
		||||
        const buf = Buffer.from(str, 'base64');
 | 
			
		||||
        // Our AES-GCM cipher text must be at least 32 bytes (IV 16 + TAG 16)
 | 
			
		||||
        return buf.length >= 32;
 | 
			
		||||
    } catch {
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    initializeKey,
 | 
			
		||||
    resetSessionKey,
 | 
			
		||||
    encrypt,
 | 
			
		||||
    decrypt,
 | 
			
		||||
    looksEncrypted,
 | 
			
		||||
}; 
 | 
			
		||||
@ -1,639 +0,0 @@
 | 
			
		||||
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;
 | 
			
		||||
							
								
								
									
										277
									
								
								src/features/common/services/localAIServiceBase.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										277
									
								
								src/features/common/services/localAIServiceBase.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,277 @@
 | 
			
		||||
const { exec } = require('child_process');
 | 
			
		||||
const { promisify } = require('util');
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
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();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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);
 | 
			
		||||
        this.emit('install-progress', { model: 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
 | 
			
		||||
        } = 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 (onProgress && totalSize > 0) {
 | 
			
		||||
                        const progress = Math.round((downloadedSize / totalSize) * 100);
 | 
			
		||||
                        onProgress(progress, downloadedSize, totalSize);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                response.pipe(file);
 | 
			
		||||
 | 
			
		||||
                file.on('finish', () => {
 | 
			
		||||
                    file.close(() => {
 | 
			
		||||
                        this.emit('download-complete', { url, destination, size: downloadedSize });
 | 
			
		||||
                        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 });
 | 
			
		||||
                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, ...downloadOptions } = options;
 | 
			
		||||
        
 | 
			
		||||
        for (let attempt = 1; attempt <= maxRetries; attempt++) {
 | 
			
		||||
            try {
 | 
			
		||||
                const result = await this.downloadFile(url, destination, downloadOptions);
 | 
			
		||||
                
 | 
			
		||||
                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);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = LocalAIServiceBase;
 | 
			
		||||
							
								
								
									
										138
									
								
								src/features/common/services/localProgressTracker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										138
									
								
								src/features/common/services/localProgressTracker.js
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,138 @@
 | 
			
		||||
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,437 +1,588 @@
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
const Store = require('electron-store');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
const { ipcMain, webContents } = require('electron');
 | 
			
		||||
const { PROVIDERS, getProviderClass } = require('../ai/factory');
 | 
			
		||||
const encryptionService = require('./encryptionService');
 | 
			
		||||
const providerSettingsRepository = require('../repositories/providerSettings');
 | 
			
		||||
const authService = require('./authService');
 | 
			
		||||
const ollamaModelRepository = require('../repositories/ollamaModel');
 | 
			
		||||
const userModelSelectionsRepository = require('../repositories/userModelSelections');
 | 
			
		||||
 | 
			
		||||
class ModelStateService extends EventEmitter {
 | 
			
		||||
// Import authService directly (singleton)
 | 
			
		||||
const authService = require('./authService');
 | 
			
		||||
 | 
			
		||||
class ModelStateService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.authService = authService;
 | 
			
		||||
        // electron-store는 오직 레거시 데이터 마이그레이션 용도로만 사용됩니다.
 | 
			
		||||
        this.store = new Store({ name: 'pickle-glass-model-state' });
 | 
			
		||||
        this.state = {};
 | 
			
		||||
        this.hasMigrated = false;
 | 
			
		||||
        
 | 
			
		||||
        // Set auth service for repositories
 | 
			
		||||
        providerSettingsRepository.setAuthService(authService);
 | 
			
		||||
        userModelSelectionsRepository.setAuthService(authService);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initialize() {
 | 
			
		||||
        console.log('[ModelStateService] Initializing one-time setup...');
 | 
			
		||||
        await this._initializeEncryption();
 | 
			
		||||
        await this._runMigrations();
 | 
			
		||||
        this.setupLocalAIStateSync();
 | 
			
		||||
        await this._autoSelectAvailableModels([], true);
 | 
			
		||||
        console.log('[ModelStateService] One-time setup complete.');
 | 
			
		||||
        console.log('[ModelStateService] Initializing...');
 | 
			
		||||
        await this._loadStateForCurrentUser();
 | 
			
		||||
        console.log('[ModelStateService] Initialization complete');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _initializeEncryption() {
 | 
			
		||||
        try {
 | 
			
		||||
            const rows = await providerSettingsRepository.getRawApiKeys();
 | 
			
		||||
            if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) {
 | 
			
		||||
                console.log('[ModelStateService] Encrypted keys detected, initializing encryption...');
 | 
			
		||||
                const userIdForMigration = this.authService.getCurrentUserId();
 | 
			
		||||
                await encryptionService.initializeKey(userIdForMigration);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.');
 | 
			
		||||
            }
 | 
			
		||||
        } catch (err) {
 | 
			
		||||
            console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _runMigrations() {
 | 
			
		||||
        console.log('[ModelStateService] Checking for data migrations...');
 | 
			
		||||
        const userId = this.authService.getCurrentUserId();
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            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) {
 | 
			
		||||
                const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId);
 | 
			
		||||
                if (selections) {
 | 
			
		||||
                    console.log('[ModelStateService] Migrating from user_model_selections table...');
 | 
			
		||||
                    if (selections.llm_model) {
 | 
			
		||||
                        const llmProvider = this.getProviderForModel(selections.llm_model, 'llm');
 | 
			
		||||
                        if (llmProvider) {
 | 
			
		||||
                            await this.setSelectedModel('llm', selections.llm_model);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    if (selections.stt_model) {
 | 
			
		||||
                        const sttProvider = this.getProviderForModel(selections.stt_model, 'stt');
 | 
			
		||||
                        if (sttProvider) {
 | 
			
		||||
                            await this.setSelectedModel('stt', selections.stt_model);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                    db.prepare('DROP TABLE user_model_selections').run();
 | 
			
		||||
                    console.log('[ModelStateService] user_model_selections migration complete.');
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ModelStateService] user_model_selections migration failed:', error);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const legacyData = this.store.get(`users.${userId}`);
 | 
			
		||||
            if (legacyData && legacyData.apiKeys) {
 | 
			
		||||
                console.log('[ModelStateService] Migrating from electron-store...');
 | 
			
		||||
                for (const [provider, apiKey] of Object.entries(legacyData.apiKeys)) {
 | 
			
		||||
                    if (apiKey && PROVIDERS[provider]) {
 | 
			
		||||
                        await this.setApiKey(provider, apiKey);
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
                if (legacyData.selectedModels?.llm) {
 | 
			
		||||
                    await this.setSelectedModel('llm', legacyData.selectedModels.llm);
 | 
			
		||||
                }
 | 
			
		||||
                if (legacyData.selectedModels?.stt) {
 | 
			
		||||
                    await this.setSelectedModel('stt', legacyData.selectedModels.stt);
 | 
			
		||||
                }
 | 
			
		||||
                this.store.delete(`users.${userId}`);
 | 
			
		||||
                console.log('[ModelStateService] electron-store migration complete.');
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ModelStateService] electron-store migration failed:', error);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    _logCurrentSelection() {
 | 
			
		||||
        const llmModel = this.state.selectedModels.llm;
 | 
			
		||||
        const sttModel = this.state.selectedModels.stt;
 | 
			
		||||
        const llmProvider = this.getProviderForModel('llm', llmModel) || 'None';
 | 
			
		||||
        const sttProvider = this.getProviderForModel('stt', sttModel) || 'None';
 | 
			
		||||
    
 | 
			
		||||
    setupLocalAIStateSync() {
 | 
			
		||||
        const localAIManager = require('./localAIManager');
 | 
			
		||||
        localAIManager.on('state-changed', (service, status) => {
 | 
			
		||||
            this.handleLocalAIStateChange(service, status);
 | 
			
		||||
        });
 | 
			
		||||
        console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleLocalAIStateChange(service, state) {
 | 
			
		||||
        console.log(`[ModelStateService] LocalAI state changed: ${service}`, state);
 | 
			
		||||
        if (!state.installed || !state.running) {
 | 
			
		||||
            const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : [];
 | 
			
		||||
            await this._autoSelectAvailableModels(types);
 | 
			
		||||
        }
 | 
			
		||||
        this.emit('state-updated', await this.getLiveState());
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getLiveState() {
 | 
			
		||||
        const providerSettings = await providerSettingsRepository.getAll();
 | 
			
		||||
        const apiKeys = {};
 | 
			
		||||
        Object.keys(PROVIDERS).forEach(provider => {
 | 
			
		||||
            const setting = providerSettings.find(s => s.provider === provider);
 | 
			
		||||
            apiKeys[provider] = setting?.api_key || null;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const activeSettings = await providerSettingsRepository.getActiveSettings();
 | 
			
		||||
        const selectedModels = {
 | 
			
		||||
            llm: activeSettings.llm?.selected_llm_model || null,
 | 
			
		||||
            stt: activeSettings.stt?.selected_stt_model || null
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        return { apiKeys, selectedModels };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _autoSelectAvailableModels(forceReselectionForTypes = [], isInitialBoot = false) {
 | 
			
		||||
        console.log(`[ModelStateService] Running auto-selection. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
 | 
			
		||||
        const { apiKeys, selectedModels } = await this.getLiveState();
 | 
			
		||||
    _autoSelectAvailableModels(forceReselectionForTypes = []) {
 | 
			
		||||
        console.log(`[ModelStateService] Running auto-selection for models. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
 | 
			
		||||
        const types = ['llm', 'stt'];
 | 
			
		||||
 | 
			
		||||
        for (const type of types) {
 | 
			
		||||
            const currentModelId = selectedModels[type];
 | 
			
		||||
        types.forEach(type => {
 | 
			
		||||
            const currentModelId = this.state.selectedModels[type];
 | 
			
		||||
            let isCurrentModelValid = false;
 | 
			
		||||
 | 
			
		||||
            const forceReselection = forceReselectionForTypes.includes(type);
 | 
			
		||||
 | 
			
		||||
            if (currentModelId && !forceReselection) {
 | 
			
		||||
                const provider = this.getProviderForModel(currentModelId, type);
 | 
			
		||||
                const apiKey = apiKeys[provider];
 | 
			
		||||
                if (provider && apiKey) {
 | 
			
		||||
                const provider = this.getProviderForModel(type, currentModelId);
 | 
			
		||||
                const apiKey = this.getApiKey(provider);
 | 
			
		||||
                // For Ollama, 'local' is a valid API key
 | 
			
		||||
                if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) {
 | 
			
		||||
                    isCurrentModelValid = true;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!isCurrentModelValid) {
 | 
			
		||||
                console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`);
 | 
			
		||||
                const availableModels = await this.getAvailableModels(type);
 | 
			
		||||
                console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or re-selection forced. Finding an alternative...`);
 | 
			
		||||
                const availableModels = this.getAvailableModels(type);
 | 
			
		||||
                if (availableModels.length > 0) {
 | 
			
		||||
                    // Prefer API providers over local providers for auto-selection
 | 
			
		||||
                    const apiModel = availableModels.find(model => {
 | 
			
		||||
                        const provider = this.getProviderForModel(model.id, type);
 | 
			
		||||
                        const provider = this.getProviderForModel(type, model.id);
 | 
			
		||||
                        return provider && provider !== 'ollama' && provider !== 'whisper';
 | 
			
		||||
                    });
 | 
			
		||||
                    const newModel = apiModel || availableModels[0];
 | 
			
		||||
                    await this.setSelectedModel(type, newModel.id);
 | 
			
		||||
                    console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`);
 | 
			
		||||
                    
 | 
			
		||||
                    const selectedModel = apiModel || availableModels[0];
 | 
			
		||||
                    this.state.selectedModels[type] = selectedModel.id;
 | 
			
		||||
                    console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${selectedModel.id} (preferred: ${apiModel ? 'API' : 'local'})`);
 | 
			
		||||
                } else {
 | 
			
		||||
                    await providerSettingsRepository.setActiveProvider(null, type);
 | 
			
		||||
                    if (!isInitialBoot) {
 | 
			
		||||
                       this.emit('state-updated', await this.getLiveState());
 | 
			
		||||
                    this.state.selectedModels[type] = null;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _migrateFromElectronStore() {
 | 
			
		||||
        console.log('[ModelStateService] Starting migration from electron-store to database...');
 | 
			
		||||
        const userId = this.authService.getCurrentUserId();
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            // Get data from electron-store
 | 
			
		||||
            const legacyData = this.store.get(`users.${userId}`, null);
 | 
			
		||||
            
 | 
			
		||||
            if (!legacyData) {
 | 
			
		||||
                console.log('[ModelStateService] No legacy data to migrate');
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            console.log('[ModelStateService] Found legacy data, migrating...');
 | 
			
		||||
            
 | 
			
		||||
            // Migrate provider settings (API keys and selected models per provider)
 | 
			
		||||
            const { apiKeys = {}, selectedModels = {} } = legacyData;
 | 
			
		||||
            
 | 
			
		||||
            for (const [provider, apiKey] of Object.entries(apiKeys)) {
 | 
			
		||||
                if (apiKey && PROVIDERS[provider]) {
 | 
			
		||||
                    // For encrypted keys, they are already decrypted in _loadStateForCurrentUser
 | 
			
		||||
                    await providerSettingsRepository.upsert(provider, {
 | 
			
		||||
                        api_key: apiKey
 | 
			
		||||
                    });
 | 
			
		||||
                    console.log(`[ModelStateService] Migrated API key for ${provider}`);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // 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');
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Mark migration as complete by removing legacy data
 | 
			
		||||
            this.store.delete(`users.${userId}`);
 | 
			
		||||
            console.log('[ModelStateService] Migration completed and legacy data cleaned up');
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ModelStateService] Migration failed:', error);
 | 
			
		||||
            // Don't throw - continue with normal operation
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _loadStateFromDatabase() {
 | 
			
		||||
        console.log('[ModelStateService] Loading state from database...');
 | 
			
		||||
        const userId = this.authService.getCurrentUserId();
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            // Load provider settings
 | 
			
		||||
            const providerSettings = await providerSettingsRepository.getAllByUid();
 | 
			
		||||
            const apiKeys = {};
 | 
			
		||||
            
 | 
			
		||||
            // Reconstruct apiKeys object
 | 
			
		||||
            Object.keys(PROVIDERS).forEach(provider => {
 | 
			
		||||
                apiKeys[provider] = null;
 | 
			
		||||
            });
 | 
			
		||||
            
 | 
			
		||||
            for (const setting of providerSettings) {
 | 
			
		||||
                if (setting.api_key) {
 | 
			
		||||
                    // API keys are stored encrypted in database, decrypt them
 | 
			
		||||
                    if (setting.provider !== 'ollama' && setting.provider !== 'whisper') {
 | 
			
		||||
                        try {
 | 
			
		||||
                            apiKeys[setting.provider] = encryptionService.decrypt(setting.api_key);
 | 
			
		||||
                        } catch (error) {
 | 
			
		||||
                            console.error(`[ModelStateService] Failed to decrypt API key for ${setting.provider}, resetting`);
 | 
			
		||||
                            apiKeys[setting.provider] = null;
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        apiKeys[setting.provider] = setting.api_key;
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Load global model selections
 | 
			
		||||
            const modelSelections = await userModelSelectionsRepository.get();
 | 
			
		||||
            const selectedModels = {
 | 
			
		||||
                llm: modelSelections?.selected_llm_model || null,
 | 
			
		||||
                stt: modelSelections?.selected_stt_model || null
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            this.state = {
 | 
			
		||||
                apiKeys,
 | 
			
		||||
                selectedModels
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ModelStateService] Failed to load state from database:', error);
 | 
			
		||||
            // Fall back to default state
 | 
			
		||||
            const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => {
 | 
			
		||||
                acc[key] = null;
 | 
			
		||||
                return acc;
 | 
			
		||||
            }, {});
 | 
			
		||||
            
 | 
			
		||||
            this.state = {
 | 
			
		||||
                apiKeys: initialApiKeys,
 | 
			
		||||
                selectedModels: { llm: null, stt: null },
 | 
			
		||||
            };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _loadStateForCurrentUser() {
 | 
			
		||||
        const userId = this.authService.getCurrentUserId();
 | 
			
		||||
        
 | 
			
		||||
        // Initialize encryption service for current user
 | 
			
		||||
        await encryptionService.initializeKey(userId);
 | 
			
		||||
        
 | 
			
		||||
        // Try to load from database first
 | 
			
		||||
        await this._loadStateFromDatabase();
 | 
			
		||||
        
 | 
			
		||||
        // Check if we need to migrate from electron-store
 | 
			
		||||
        const legacyData = this.store.get(`users.${userId}`, null);
 | 
			
		||||
        if (legacyData && !this.hasMigrated) {
 | 
			
		||||
            await this._migrateFromElectronStore();
 | 
			
		||||
            // Reload state after migration
 | 
			
		||||
            await this._loadStateFromDatabase();
 | 
			
		||||
            this.hasMigrated = true;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this._autoSelectAvailableModels();
 | 
			
		||||
        await this._saveState();
 | 
			
		||||
        this._logCurrentSelection();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _saveState() {
 | 
			
		||||
        console.log('[ModelStateService] Saving state to database...');
 | 
			
		||||
        const userId = this.authService.getCurrentUserId();
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            // Save provider settings (API keys)
 | 
			
		||||
            for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) {
 | 
			
		||||
                if (apiKey) {
 | 
			
		||||
                    const encryptedKey = (provider !== 'ollama' && provider !== 'whisper') 
 | 
			
		||||
                        ? encryptionService.encrypt(apiKey)
 | 
			
		||||
                        : apiKey;
 | 
			
		||||
                        
 | 
			
		||||
                    await providerSettingsRepository.upsert(provider, {
 | 
			
		||||
                        api_key: encryptedKey
 | 
			
		||||
                    });
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Remove empty API keys
 | 
			
		||||
                    await providerSettingsRepository.remove(provider);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // 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;
 | 
			
		||||
            
 | 
			
		||||
            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
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            console.log(`[ModelStateService] State saved to database for user: ${userId}`);
 | 
			
		||||
            this._logCurrentSelection();
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ModelStateService] Failed to save state to database:', error);
 | 
			
		||||
            // Fall back to electron-store for now
 | 
			
		||||
            this._saveStateToElectronStore();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _saveStateToElectronStore() {
 | 
			
		||||
        console.log('[ModelStateService] Falling back to electron-store...');
 | 
			
		||||
        const userId = this.authService.getCurrentUserId();
 | 
			
		||||
        const stateToSave = {
 | 
			
		||||
            ...this.state,
 | 
			
		||||
            apiKeys: { ...this.state.apiKeys }
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
 | 
			
		||||
            if (key && provider !== 'ollama' && provider !== 'whisper') {
 | 
			
		||||
                try {
 | 
			
		||||
                    stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error(`[ModelStateService] Failed to encrypt API key for ${provider}`);
 | 
			
		||||
                    stateToSave.apiKeys[provider] = null;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this.store.set(`users.${userId}`, stateToSave);
 | 
			
		||||
        console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`);
 | 
			
		||||
        this._logCurrentSelection();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async validateApiKey(provider, key) {
 | 
			
		||||
        if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
 | 
			
		||||
            return { success: false, error: 'API key cannot be empty.' };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const ProviderClass = getProviderClass(provider);
 | 
			
		||||
 | 
			
		||||
        if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
 | 
			
		||||
            // Default to success if no specific validator is found
 | 
			
		||||
            console.warn(`[ModelStateService] No validateApiKey function for provider: ${provider}. Assuming valid.`);
 | 
			
		||||
                    return { success: true };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const result = await ProviderClass.validateApiKey(key);
 | 
			
		||||
            if (result.success) {
 | 
			
		||||
                console.log(`[ModelStateService] API key for ${provider} is valid.`);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log(`[ModelStateService] API key for ${provider} is invalid: ${result.error}`);
 | 
			
		||||
            }
 | 
			
		||||
            return result;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error(`[ModelStateService] Error during ${provider} key validation:`, error);
 | 
			
		||||
            return { success: false, error: 'An unexpected error occurred during validation.' };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    async setFirebaseVirtualKey(virtualKey) {
 | 
			
		||||
        console.log(`[ModelStateService] Setting Firebase virtual key.`);
 | 
			
		||||
    setFirebaseVirtualKey(virtualKey) {
 | 
			
		||||
        console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`);
 | 
			
		||||
        this.state.apiKeys['openai-glass'] = virtualKey;
 | 
			
		||||
        
 | 
			
		||||
        const llmModels = PROVIDERS['openai-glass']?.llmModels;
 | 
			
		||||
        const sttModels = PROVIDERS['openai-glass']?.sttModels;
 | 
			
		||||
 | 
			
		||||
        // 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다.
 | 
			
		||||
        const previousSettings = await providerSettingsRepository.getByProvider('openai-glass');
 | 
			
		||||
        const wasPreviouslyConfigured = !!previousSettings?.api_key;
 | 
			
		||||
 | 
			
		||||
        // 항상 새로운 가상 키로 업데이트합니다.
 | 
			
		||||
        await this.setApiKey('openai-glass', virtualKey);
 | 
			
		||||
 | 
			
		||||
        if (virtualKey) {
 | 
			
		||||
            // 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다.
 | 
			
		||||
            if (!wasPreviouslyConfigured) {
 | 
			
		||||
                console.log('[ModelStateService] First-time setup for openai-glass, setting default models.');
 | 
			
		||||
                const llmModel = PROVIDERS['openai-glass']?.llmModels[0];
 | 
			
		||||
                const sttModel = PROVIDERS['openai-glass']?.sttModels[0];
 | 
			
		||||
                if (llmModel) await this.setSelectedModel('llm', llmModel.id);
 | 
			
		||||
                if (sttModel) await this.setSelectedModel('stt', sttModel.id);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.');
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            // 로그아웃 시, 현재 활성화된 모델이 openai-glass인 경우에만 다른 모델로 전환합니다.
 | 
			
		||||
            const selected = await this.getSelectedModels();
 | 
			
		||||
            const llmProvider = this.getProviderForModel(selected.llm, 'llm');
 | 
			
		||||
            const sttProvider = this.getProviderForModel(selected.stt, 'stt');
 | 
			
		||||
            
 | 
			
		||||
            const typesToReselect = [];
 | 
			
		||||
            if (llmProvider === 'openai-glass') typesToReselect.push('llm');
 | 
			
		||||
            if (sttProvider === 'openai-glass') typesToReselect.push('stt');
 | 
			
		||||
 | 
			
		||||
            if (typesToReselect.length > 0) {
 | 
			
		||||
                console.log('[ModelStateService] Logged out, re-selecting models for:', typesToReselect.join(', '));
 | 
			
		||||
                await this._autoSelectAvailableModels(typesToReselect);
 | 
			
		||||
            }
 | 
			
		||||
        // When logging in with Pickle, prioritize Pickle's models over existing selections
 | 
			
		||||
        if (virtualKey && llmModels?.length > 0) {
 | 
			
		||||
            this.state.selectedModels.llm = llmModels[0].id;
 | 
			
		||||
            console.log(`[ModelStateService] Prioritized Pickle LLM model: ${llmModels[0].id}`);
 | 
			
		||||
        }
 | 
			
		||||
        if (virtualKey && sttModels?.length > 0) {
 | 
			
		||||
            this.state.selectedModels.stt = sttModels[0].id;
 | 
			
		||||
            console.log(`[ModelStateService] Prioritized Pickle STT model: ${sttModels[0].id}`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // If logging out (virtualKey is null), run auto-selection to find alternatives
 | 
			
		||||
        if (!virtualKey) {
 | 
			
		||||
            this._autoSelectAvailableModels();
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this._saveState();
 | 
			
		||||
        this._logCurrentSelection();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async setApiKey(provider, key) {
 | 
			
		||||
        console.log(`[ModelStateService] setApiKey for ${provider}`);
 | 
			
		||||
        if (!provider) {
 | 
			
		||||
            throw new Error('Provider is required');
 | 
			
		||||
        }
 | 
			
		||||
        if (provider in this.state.apiKeys) {
 | 
			
		||||
            this.state.apiKeys[provider] = key;
 | 
			
		||||
 | 
			
		||||
        // 'openai-glass'는 자체 인증 키를 사용하므로 유효성 검사를 건너뜁니다.
 | 
			
		||||
        if (provider !== 'openai-glass') {
 | 
			
		||||
            const validationResult = await this.validateApiKey(provider, key);
 | 
			
		||||
            if (!validationResult.success) {
 | 
			
		||||
                console.warn(`[ModelStateService] API key validation failed for ${provider}: ${validationResult.error}`);
 | 
			
		||||
                return validationResult;
 | 
			
		||||
            const supportedTypes = [];
 | 
			
		||||
            if (PROVIDERS[provider]?.llmModels.length > 0 || provider === 'ollama') {
 | 
			
		||||
                supportedTypes.push('llm');
 | 
			
		||||
            }
 | 
			
		||||
            if (PROVIDERS[provider]?.sttModels.length > 0 || provider === 'whisper') {
 | 
			
		||||
                supportedTypes.push('stt');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
 | 
			
		||||
        const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
 | 
			
		||||
        await providerSettingsRepository.upsert(provider, { ...existingSettings, api_key: finalKey });
 | 
			
		||||
        
 | 
			
		||||
        // 키가 추가/변경되었으므로, 해당 provider의 모델을 자동 선택할 수 있는지 확인
 | 
			
		||||
        await this._autoSelectAvailableModels([]);
 | 
			
		||||
        
 | 
			
		||||
        this.emit('state-updated', await this.getLiveState());
 | 
			
		||||
        this.emit('settings-updated');
 | 
			
		||||
        return { success: true };
 | 
			
		||||
            this._autoSelectAvailableModels(supportedTypes);
 | 
			
		||||
            await this._saveState();
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAllApiKeys() {
 | 
			
		||||
        const allSettings = await providerSettingsRepository.getAll();
 | 
			
		||||
        const apiKeys = {};
 | 
			
		||||
        allSettings.forEach(s => {
 | 
			
		||||
            if (s.provider !== 'openai-glass') {
 | 
			
		||||
                apiKeys[s.provider] = s.api_key;
 | 
			
		||||
    getApiKey(provider) {
 | 
			
		||||
        return this.state.apiKeys[provider];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getAllApiKeys() {
 | 
			
		||||
        const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys;
 | 
			
		||||
        return displayKeys;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    removeApiKey(provider) {
 | 
			
		||||
        console.log(`[ModelStateService] Removing API key for provider: ${provider}`);
 | 
			
		||||
        if (provider in this.state.apiKeys) {
 | 
			
		||||
            this.state.apiKeys[provider] = null;
 | 
			
		||||
            const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm);
 | 
			
		||||
            if (llmProvider === provider) this.state.selectedModels.llm = null;
 | 
			
		||||
 | 
			
		||||
            const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt);
 | 
			
		||||
            if (sttProvider === provider) this.state.selectedModels.stt = null;
 | 
			
		||||
            
 | 
			
		||||
            this._autoSelectAvailableModels();
 | 
			
		||||
            this._saveState();
 | 
			
		||||
            this._logCurrentSelection();
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getProviderForModel(type, modelId) {
 | 
			
		||||
        if (!modelId) return null;
 | 
			
		||||
        for (const providerId in PROVIDERS) {
 | 
			
		||||
            const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
 | 
			
		||||
            if (models.some(m => m.id === modelId)) {
 | 
			
		||||
                return providerId;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // If no provider was found, assume it could be a custom Ollama model
 | 
			
		||||
        // if Ollama provider is configured (has a key).
 | 
			
		||||
        if (type === 'llm' && this.state.apiKeys['ollama']) {
 | 
			
		||||
            console.log(`[ModelStateService] Model '${modelId}' not found in PROVIDERS list, assuming it's a custom Ollama model.`);
 | 
			
		||||
            return 'ollama';
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getCurrentProvider(type) {
 | 
			
		||||
        const selectedModel = this.state.selectedModels[type];
 | 
			
		||||
        return this.getProviderForModel(type, selectedModel);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    isLoggedInWithFirebase() {
 | 
			
		||||
        return this.authService.getCurrentUser().isLoggedIn;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    areProvidersConfigured() {
 | 
			
		||||
        if (this.isLoggedInWithFirebase()) return true;
 | 
			
		||||
        
 | 
			
		||||
        console.log('[DEBUG] Checking configured providers with apiKeys state:', JSON.stringify(this.state.apiKeys, (key, value) => (value ? '***' : null), 2));
 | 
			
		||||
 | 
			
		||||
        // LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
 | 
			
		||||
        const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
 | 
			
		||||
            if (provider === 'ollama') {
 | 
			
		||||
                // Ollama uses dynamic models, so just check if configured (has 'local' key)
 | 
			
		||||
                return key === 'local';
 | 
			
		||||
            }
 | 
			
		||||
            if (provider === 'whisper') {
 | 
			
		||||
                // Whisper doesn't support LLM
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
            return key && PROVIDERS[provider]?.llmModels.length > 0;
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
 | 
			
		||||
            if (provider === 'whisper') {
 | 
			
		||||
                // Whisper has static model list and supports STT
 | 
			
		||||
                return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0;
 | 
			
		||||
            }
 | 
			
		||||
            if (provider === 'ollama') {
 | 
			
		||||
                // Ollama doesn't support STT yet
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
            return key && PROVIDERS[provider]?.sttModels.length > 0;
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        const result = hasLlmKey && hasSttKey;
 | 
			
		||||
        console.log(`[ModelStateService] areProvidersConfigured: LLM=${hasLlmKey}, STT=${hasSttKey}, result=${result}`);
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    hasValidApiKey() {
 | 
			
		||||
        if (this.isLoggedInWithFirebase()) return true;
 | 
			
		||||
        
 | 
			
		||||
        // Check if any provider has a valid API key
 | 
			
		||||
        return Object.entries(this.state.apiKeys).some(([provider, key]) => {
 | 
			
		||||
            if (provider === 'ollama' || provider === 'whisper') {
 | 
			
		||||
                return key === 'local';
 | 
			
		||||
            }
 | 
			
		||||
            return key && key.trim().length > 0;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    getAvailableModels(type) {
 | 
			
		||||
        const available = [];
 | 
			
		||||
        const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
 | 
			
		||||
 | 
			
		||||
        Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
 | 
			
		||||
            if (key && PROVIDERS[providerId]?.[modelList]) {
 | 
			
		||||
                available.push(...PROVIDERS[providerId][modelList]);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        return apiKeys;
 | 
			
		||||
        return [...new Map(available.map(item => [item.id, item])).values()];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async removeApiKey(provider) {
 | 
			
		||||
        const setting = await providerSettingsRepository.getByProvider(provider);
 | 
			
		||||
        if (setting && setting.api_key) {
 | 
			
		||||
            await providerSettingsRepository.upsert(provider, { ...setting, api_key: null });
 | 
			
		||||
            await this._autoSelectAvailableModels(['llm', 'stt']);
 | 
			
		||||
            this.emit('state-updated', await this.getLiveState());
 | 
			
		||||
            this.emit('settings-updated');
 | 
			
		||||
    
 | 
			
		||||
    getSelectedModels() {
 | 
			
		||||
        return this.state.selectedModels;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    setSelectedModel(type, modelId) {
 | 
			
		||||
        const provider = this.getProviderForModel(type, modelId);
 | 
			
		||||
        if (provider && this.state.apiKeys[provider]) {
 | 
			
		||||
            const previousModel = this.state.selectedModels[type];
 | 
			
		||||
            this.state.selectedModels[type] = modelId;
 | 
			
		||||
            this._saveState();
 | 
			
		||||
            
 | 
			
		||||
            // Auto warm-up for Ollama LLM models when changed
 | 
			
		||||
            if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
 | 
			
		||||
                this._autoWarmUpOllamaModel(modelId, previousModel);
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 사용자가 Firebase에 로그인했는지 확인합니다.
 | 
			
		||||
     * Auto warm-up Ollama model when LLM selection changes
 | 
			
		||||
     * @private
 | 
			
		||||
     * @param {string} newModelId - The newly selected model
 | 
			
		||||
     * @param {string} previousModelId - The previously selected model
 | 
			
		||||
     */
 | 
			
		||||
    isLoggedInWithFirebase() {
 | 
			
		||||
        return this.authService.getCurrentUser().isLoggedIn;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 유효한 API 키가 하나라도 설정되어 있는지 확인합니다.
 | 
			
		||||
     */
 | 
			
		||||
    async hasValidApiKey() {
 | 
			
		||||
        if (this.isLoggedInWithFirebase()) return true;
 | 
			
		||||
        
 | 
			
		||||
        const allSettings = await providerSettingsRepository.getAll();
 | 
			
		||||
        return allSettings.some(s => s.api_key && s.api_key.trim().length > 0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getProviderForModel(arg1, arg2) {
 | 
			
		||||
        // Compatibility: support both (type, modelId) old order and (modelId, type) new order
 | 
			
		||||
        let type, modelId;
 | 
			
		||||
        if (arg1 === 'llm' || arg1 === 'stt') {
 | 
			
		||||
            type = arg1;
 | 
			
		||||
            modelId = arg2;
 | 
			
		||||
        } else {
 | 
			
		||||
            modelId = arg1;
 | 
			
		||||
            type = arg2;
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelId || !type) return null;
 | 
			
		||||
        for (const providerId in PROVIDERS) {
 | 
			
		||||
            const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
 | 
			
		||||
            if (models && models.some(m => m.id === modelId)) {
 | 
			
		||||
                return providerId;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        if (type === 'llm') {
 | 
			
		||||
            const installedModels = ollamaModelRepository.getInstalledModels();
 | 
			
		||||
            if (installedModels.some(m => m.name === modelId)) return 'ollama';
 | 
			
		||||
        }
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getSelectedModels() {
 | 
			
		||||
        const active = await providerSettingsRepository.getActiveSettings();
 | 
			
		||||
        return {
 | 
			
		||||
            llm: active.llm?.selected_llm_model || null,
 | 
			
		||||
            stt: active.stt?.selected_stt_model || null,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    async setSelectedModel(type, modelId) {
 | 
			
		||||
        const provider = this.getProviderForModel(modelId, type);
 | 
			
		||||
        if (!provider) {
 | 
			
		||||
            console.warn(`[ModelStateService] No provider found for model ${modelId}`);
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
 | 
			
		||||
        const newSettings = { ...existingSettings };
 | 
			
		||||
 | 
			
		||||
        if (type === 'llm') {
 | 
			
		||||
            newSettings.selected_llm_model = modelId;
 | 
			
		||||
        } else {
 | 
			
		||||
            newSettings.selected_stt_model = modelId;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        await providerSettingsRepository.upsert(provider, newSettings);
 | 
			
		||||
        await providerSettingsRepository.setActiveProvider(provider, type);
 | 
			
		||||
        
 | 
			
		||||
        console.log(`[ModelStateService] Selected ${type} model: ${modelId} (provider: ${provider})`);
 | 
			
		||||
        
 | 
			
		||||
        if (type === 'llm' && provider === 'ollama') {
 | 
			
		||||
            require('./localAIManager').warmUpModel(modelId).catch(err => console.warn(err));
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this.emit('state-updated', await this.getLiveState());
 | 
			
		||||
        this.emit('settings-updated');
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getAvailableModels(type) {
 | 
			
		||||
        const allSettings = await providerSettingsRepository.getAll();
 | 
			
		||||
        const available = [];
 | 
			
		||||
        const modelListKey = type === 'llm' ? 'llmModels' : 'sttModels';
 | 
			
		||||
 | 
			
		||||
        for (const setting of allSettings) {
 | 
			
		||||
            if (!setting.api_key) continue;
 | 
			
		||||
 | 
			
		||||
            const providerId = setting.provider;
 | 
			
		||||
            if (providerId === 'ollama' && type === 'llm') {
 | 
			
		||||
                const installed = ollamaModelRepository.getInstalledModels();
 | 
			
		||||
                available.push(...installed.map(m => ({ id: m.name, name: m.name })));
 | 
			
		||||
            } else if (PROVIDERS[providerId]?.[modelListKey]) {
 | 
			
		||||
                available.push(...PROVIDERS[providerId][modelListKey]);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return [...new Map(available.map(item => [item.id, item])).values()];
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getCurrentModelInfo(type) {
 | 
			
		||||
        const activeSetting = await providerSettingsRepository.getActiveProvider(type);
 | 
			
		||||
        if (!activeSetting) return null;
 | 
			
		||||
        
 | 
			
		||||
        const model = type === 'llm' ? activeSetting.selected_llm_model : activeSetting.selected_stt_model;
 | 
			
		||||
        if (!model) return null;
 | 
			
		||||
 | 
			
		||||
        return {
 | 
			
		||||
            provider: activeSetting.provider,
 | 
			
		||||
            model: model,
 | 
			
		||||
            apiKey: activeSetting.api_key,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // --- 핸들러 및 유틸리티 메서드 ---
 | 
			
		||||
 | 
			
		||||
    async validateApiKey(provider, key) {
 | 
			
		||||
        if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
 | 
			
		||||
            return { success: false, error: 'API key cannot be empty.' };
 | 
			
		||||
        }
 | 
			
		||||
        const ProviderClass = getProviderClass(provider);
 | 
			
		||||
        if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        }
 | 
			
		||||
    async _autoWarmUpOllamaModel(newModelId, previousModelId) {
 | 
			
		||||
        try {
 | 
			
		||||
            return await ProviderClass.validateApiKey(key);
 | 
			
		||||
            console.log(`[ModelStateService] 🔥 LLM model changed: ${previousModelId || 'None'} → ${newModelId}, triggering warm-up`);
 | 
			
		||||
            
 | 
			
		||||
            // Get Ollama service if available
 | 
			
		||||
            const ollamaService = require('./ollamaService');
 | 
			
		||||
            if (!ollamaService) {
 | 
			
		||||
                console.log('[ModelStateService] OllamaService not available for auto warm-up');
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Delay warm-up slightly to allow UI to update first
 | 
			
		||||
            setTimeout(async () => {
 | 
			
		||||
                try {
 | 
			
		||||
                    console.log(`[ModelStateService] Starting background warm-up for: ${newModelId}`);
 | 
			
		||||
                    const success = await ollamaService.warmUpModel(newModelId);
 | 
			
		||||
                    
 | 
			
		||||
                    if (success) {
 | 
			
		||||
                        console.log(`[ModelStateService] ✅ Successfully warmed up model: ${newModelId}`);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`);
 | 
			
		||||
                    }
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.log(`[ModelStateService] 🚫 Error during auto warm-up for ${newModelId}:`, error.message);
 | 
			
		||||
                }
 | 
			
		||||
            }, 500); // 500ms delay
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            return { success: false, error: 'An unexpected error occurred during validation.' };
 | 
			
		||||
            console.error('[ModelStateService] Error in auto warm-up setup:', error);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getProviderConfig() {
 | 
			
		||||
        const config = {};
 | 
			
		||||
        const serializableProviders = {};
 | 
			
		||||
        for (const key in PROVIDERS) {
 | 
			
		||||
            const { handler, ...rest } = PROVIDERS[key];
 | 
			
		||||
            config[key] = rest;
 | 
			
		||||
            serializableProviders[key] = rest;
 | 
			
		||||
        }
 | 
			
		||||
        return config;
 | 
			
		||||
        return serializableProviders;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    async handleValidateKey(provider, key) {
 | 
			
		||||
        const result = await this.validateApiKey(provider, key);
 | 
			
		||||
        if (result.success) {
 | 
			
		||||
            // Use 'local' as placeholder for local services
 | 
			
		||||
            const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
 | 
			
		||||
            await this.setApiKey(provider, finalKey);
 | 
			
		||||
        }
 | 
			
		||||
        return result;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleRemoveApiKey(provider) {
 | 
			
		||||
        const success = await this.removeApiKey(provider);
 | 
			
		||||
        console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`);
 | 
			
		||||
        const success = this.removeApiKey(provider);
 | 
			
		||||
        if (success) {
 | 
			
		||||
            const selectedModels = await this.getSelectedModels();
 | 
			
		||||
            if (!selectedModels.llm && !selectedModels.stt) {
 | 
			
		||||
                this.emit('force-show-apikey-header');
 | 
			
		||||
            const selectedModels = this.getSelectedModels();
 | 
			
		||||
            if (!selectedModels.llm || !selectedModels.stt) {
 | 
			
		||||
                webContents.getAllWebContents().forEach(wc => {
 | 
			
		||||
                    wc.send('force-show-apikey-header');
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return success;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /*-------------- Compatibility Helpers --------------*/
 | 
			
		||||
    async handleValidateKey(provider, key) {
 | 
			
		||||
        return await this.setApiKey(provider, key);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleSetSelectedModel(type, modelId) {
 | 
			
		||||
        return await this.setSelectedModel(type, modelId);
 | 
			
		||||
        return this.setSelectedModel(type, modelId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async areProvidersConfigured() {
 | 
			
		||||
        if (this.isLoggedInWithFirebase()) return true;
 | 
			
		||||
        const allSettings = await providerSettingsRepository.getAll();
 | 
			
		||||
        const apiKeyMap = {};
 | 
			
		||||
        allSettings.forEach(s => apiKeyMap[s.provider] = s.api_key);
 | 
			
		||||
        // LLM
 | 
			
		||||
        const hasLlmKey = Object.entries(apiKeyMap).some(([provider, key]) => {
 | 
			
		||||
            if (!key) return false;
 | 
			
		||||
            if (provider === 'whisper') return false; // whisper는 LLM 없음
 | 
			
		||||
            return PROVIDERS[provider]?.llmModels?.length > 0;
 | 
			
		||||
        });
 | 
			
		||||
        // STT
 | 
			
		||||
        const hasSttKey = Object.entries(apiKeyMap).some(([provider, key]) => {
 | 
			
		||||
            if (!key) return false;
 | 
			
		||||
            if (provider === 'ollama') return false; // ollama는 STT 없음
 | 
			
		||||
            return PROVIDERS[provider]?.sttModels?.length > 0 || provider === 'whisper';
 | 
			
		||||
        });
 | 
			
		||||
        return hasLlmKey && hasSttKey;
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {('llm' | 'stt')} type
 | 
			
		||||
     * @returns {{provider: string, model: string, apiKey: string} | null}
 | 
			
		||||
     */
 | 
			
		||||
    getCurrentModelInfo(type) {
 | 
			
		||||
        this._logCurrentSelection();
 | 
			
		||||
        const model = this.state.selectedModels[type];
 | 
			
		||||
        if (!model) {
 | 
			
		||||
            return null; 
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const provider = this.getProviderForModel(type, model);
 | 
			
		||||
        if (!provider) {
 | 
			
		||||
            return null;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const apiKey = this.getApiKey(provider);
 | 
			
		||||
        return { provider, model, apiKey };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Export singleton instance
 | 
			
		||||
const modelStateService = new ModelStateService();
 | 
			
		||||
module.exports = modelStateService;
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -2,28 +2,27 @@ const { systemPreferences, shell, desktopCapturer } = require('electron');
 | 
			
		||||
const permissionRepository = require('../repositories/permission');
 | 
			
		||||
 | 
			
		||||
class PermissionService {
 | 
			
		||||
  _getAuthService() {
 | 
			
		||||
    return require('./authService');
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkSystemPermissions() {
 | 
			
		||||
    const permissions = {
 | 
			
		||||
      microphone: 'unknown',
 | 
			
		||||
      screen: 'unknown',
 | 
			
		||||
      keychain: 'unknown',
 | 
			
		||||
      needsSetup: true
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (process.platform === 'darwin') {
 | 
			
		||||
        permissions.microphone = systemPreferences.getMediaAccessStatus('microphone');
 | 
			
		||||
        permissions.screen = systemPreferences.getMediaAccessStatus('screen');
 | 
			
		||||
        permissions.keychain = await this.checkKeychainCompleted(this._getAuthService().getCurrentUserId()) ? 'granted' : 'unknown';
 | 
			
		||||
        permissions.needsSetup = permissions.microphone !== 'granted' || permissions.screen !== 'granted' || permissions.keychain !== 'granted';
 | 
			
		||||
        const micStatus = systemPreferences.getMediaAccessStatus('microphone');
 | 
			
		||||
        console.log('[Permissions] Microphone status:', micStatus);
 | 
			
		||||
        permissions.microphone = micStatus;
 | 
			
		||||
 | 
			
		||||
        const screenStatus = systemPreferences.getMediaAccessStatus('screen');
 | 
			
		||||
        console.log('[Permissions] Screen status:', screenStatus);
 | 
			
		||||
        permissions.screen = screenStatus;
 | 
			
		||||
 | 
			
		||||
        permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted';
 | 
			
		||||
      } else {
 | 
			
		||||
        permissions.microphone = 'granted';
 | 
			
		||||
        permissions.screen = 'granted';
 | 
			
		||||
        permissions.keychain = 'granted';
 | 
			
		||||
        permissions.needsSetup = false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -34,7 +33,6 @@ class PermissionService {
 | 
			
		||||
      return {
 | 
			
		||||
        microphone: 'unknown',
 | 
			
		||||
        screen: 'unknown',
 | 
			
		||||
        keychain: 'unknown',
 | 
			
		||||
        needsSetup: true,
 | 
			
		||||
        error: error.message
 | 
			
		||||
      };
 | 
			
		||||
@ -94,27 +92,24 @@ class PermissionService {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async markKeychainCompleted() {
 | 
			
		||||
  async markPermissionsAsCompleted() {
 | 
			
		||||
    try {
 | 
			
		||||
      await permissionRepository.markKeychainCompleted(this._getAuthService().getCurrentUserId());
 | 
			
		||||
      console.log('[Permissions] Marked keychain as completed');
 | 
			
		||||
      await permissionRepository.markPermissionsAsCompleted();
 | 
			
		||||
      console.log('[Permissions] Marked permissions as completed');
 | 
			
		||||
      return { success: true };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error marking keychain as completed:', error);
 | 
			
		||||
      console.error('[Permissions] Error marking permissions as completed:', error);
 | 
			
		||||
      return { success: false, error: error.message };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkKeychainCompleted(uid) {
 | 
			
		||||
    if (uid === "default_user") {
 | 
			
		||||
      return true;
 | 
			
		||||
    }
 | 
			
		||||
  async checkPermissionsCompleted() {
 | 
			
		||||
    try {
 | 
			
		||||
      const completed = permissionRepository.checkKeychainCompleted(uid);
 | 
			
		||||
      console.log('[Permissions] Keychain completed status:', completed);
 | 
			
		||||
      const completed = await permissionRepository.checkPermissionsCompleted();
 | 
			
		||||
      console.log('[Permissions] Permissions completed status:', completed);
 | 
			
		||||
      return completed;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error checking keychain completed status:', error);
 | 
			
		||||
      console.error('[Permissions] Error checking permissions completed status:', error);
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -40,82 +40,8 @@ class SQLiteClient {
 | 
			
		||||
        return `"${identifier}"`;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _migrateProviderSettings() {
 | 
			
		||||
        const tablesInDb = this.getTablesFromDb();
 | 
			
		||||
        if (!tablesInDb.includes('provider_settings')) {
 | 
			
		||||
            return; // Table doesn't exist, no migration needed.
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        const providerSettingsInfo = this.db.prepare(`PRAGMA table_info(provider_settings)`).all();
 | 
			
		||||
        const hasUidColumn = providerSettingsInfo.some(col => col.name === 'uid');
 | 
			
		||||
    
 | 
			
		||||
        if (hasUidColumn) {
 | 
			
		||||
            console.log('[DB Migration] Old provider_settings schema detected. Starting robust migration...');
 | 
			
		||||
    
 | 
			
		||||
            try {
 | 
			
		||||
                this.db.transaction(() => {
 | 
			
		||||
                    this.db.exec('ALTER TABLE provider_settings RENAME TO provider_settings_old');
 | 
			
		||||
                    console.log('[DB Migration] Renamed provider_settings to provider_settings_old');
 | 
			
		||||
    
 | 
			
		||||
                    this.createTable('provider_settings', LATEST_SCHEMA.provider_settings);
 | 
			
		||||
                    console.log('[DB Migration] Created new provider_settings table');
 | 
			
		||||
    
 | 
			
		||||
                    // Dynamically build the migration query for robustness
 | 
			
		||||
                    const oldColumnNames = this.db.prepare(`PRAGMA table_info(provider_settings_old)`).all().map(c => c.name);
 | 
			
		||||
                    const newColumnNames = LATEST_SCHEMA.provider_settings.columns.map(c => c.name);
 | 
			
		||||
                    const commonColumns = newColumnNames.filter(name => oldColumnNames.includes(name));
 | 
			
		||||
    
 | 
			
		||||
                    if (!commonColumns.includes('provider')) {
 | 
			
		||||
                        console.warn('[DB Migration] Old table is missing the "provider" column. Aborting migration for this table.');
 | 
			
		||||
                        this.db.exec('DROP TABLE provider_settings_old');
 | 
			
		||||
                        return;
 | 
			
		||||
                    }
 | 
			
		||||
    
 | 
			
		||||
                    const orderParts = [];
 | 
			
		||||
                    if (oldColumnNames.includes('updated_at')) orderParts.push('updated_at DESC');
 | 
			
		||||
                    if (oldColumnNames.includes('created_at')) orderParts.push('created_at DESC');
 | 
			
		||||
                    const orderByClause = orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : '';
 | 
			
		||||
    
 | 
			
		||||
                    const columnsForInsert = commonColumns.map(c => this._validateAndQuoteIdentifier(c)).join(', ');
 | 
			
		||||
    
 | 
			
		||||
                    const migrationQuery = `
 | 
			
		||||
                        INSERT INTO provider_settings (${columnsForInsert})
 | 
			
		||||
                        SELECT ${columnsForInsert}
 | 
			
		||||
                        FROM (
 | 
			
		||||
                            SELECT *, ROW_NUMBER() OVER(PARTITION BY provider ${orderByClause}) as rn
 | 
			
		||||
                            FROM provider_settings_old
 | 
			
		||||
                        )
 | 
			
		||||
                        WHERE rn = 1
 | 
			
		||||
                    `;
 | 
			
		||||
                    
 | 
			
		||||
                    console.log(`[DB Migration] Executing robust migration query for columns: ${commonColumns.join(', ')}`);
 | 
			
		||||
                    const result = this.db.prepare(migrationQuery).run();
 | 
			
		||||
                    console.log(`[DB Migration] Migrated ${result.changes} rows to the new provider_settings table.`);
 | 
			
		||||
    
 | 
			
		||||
                    this.db.exec('DROP TABLE provider_settings_old');
 | 
			
		||||
                    console.log('[DB Migration] Dropped provider_settings_old table.');
 | 
			
		||||
                })();
 | 
			
		||||
                console.log('[DB Migration] provider_settings migration completed successfully.');
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('[DB Migration] Failed to migrate provider_settings table.', error);
 | 
			
		||||
                
 | 
			
		||||
                // Try to recover by dropping the temp table if it exists
 | 
			
		||||
                const oldTableExists = this.getTablesFromDb().includes('provider_settings_old');
 | 
			
		||||
                if (oldTableExists) {
 | 
			
		||||
                    this.db.exec('DROP TABLE provider_settings_old');
 | 
			
		||||
                    console.warn('[DB Migration] Cleaned up temporary old table after failure.');
 | 
			
		||||
                }
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async synchronizeSchema() {
 | 
			
		||||
    synchronizeSchema() {
 | 
			
		||||
        console.log('[DB Sync] Starting schema synchronization...');
 | 
			
		||||
 | 
			
		||||
        // Run special migration for provider_settings before the generic sync logic
 | 
			
		||||
        this._migrateProviderSettings();
 | 
			
		||||
 | 
			
		||||
        const tablesInDb = this.getTablesFromDb();
 | 
			
		||||
 | 
			
		||||
        for (const tableName of Object.keys(LATEST_SCHEMA)) {
 | 
			
		||||
@ -206,8 +132,8 @@ class SQLiteClient {
 | 
			
		||||
        console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initTables() {
 | 
			
		||||
        await this.synchronizeSchema();
 | 
			
		||||
    initTables() {
 | 
			
		||||
        this.synchronizeSchema();
 | 
			
		||||
        this.initDefaultData();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -240,6 +166,21 @@ class SQLiteClient {
 | 
			
		||||
        console.log('Default data initialized.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    markPermissionsAsCompleted() {
 | 
			
		||||
        return this.query(
 | 
			
		||||
            'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
 | 
			
		||||
            ['permissions_completed', 'true']
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    checkPermissionsCompleted() {
 | 
			
		||||
        const result = this.query(
 | 
			
		||||
            'SELECT value FROM system_settings WHERE key = ?',
 | 
			
		||||
            ['permissions_completed']
 | 
			
		||||
        );
 | 
			
		||||
        return result.length > 0 && result[0].value === 'true';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    close() {
 | 
			
		||||
        if (this.db) {
 | 
			
		||||
            try {
 | 
			
		||||
 | 
			
		||||
@ -1,40 +1,20 @@
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
const { spawn, exec } = require('child_process');
 | 
			
		||||
const { promisify } = require('util');
 | 
			
		||||
const { spawn } = require('child_process');
 | 
			
		||||
const path = require('path');
 | 
			
		||||
const fs = require('fs');
 | 
			
		||||
const os = require('os');
 | 
			
		||||
const https = require('https');
 | 
			
		||||
const crypto = require('crypto');
 | 
			
		||||
const LocalAIServiceBase = require('./localAIServiceBase');
 | 
			
		||||
const { spawnAsync } = require('../utils/spawnHelper');
 | 
			
		||||
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
 | 
			
		||||
 | 
			
		||||
const execAsync = promisify(exec);
 | 
			
		||||
 | 
			
		||||
const fsPromises = fs.promises;
 | 
			
		||||
 | 
			
		||||
class WhisperService extends EventEmitter {
 | 
			
		||||
class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.serviceName = 'WhisperService';
 | 
			
		||||
        
 | 
			
		||||
        // 경로 및 디렉토리
 | 
			
		||||
        super('WhisperService');
 | 
			
		||||
        this.isInitialized = false;
 | 
			
		||||
        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',
 | 
			
		||||
@ -59,222 +39,8 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // 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.installState.isInitialized) return;
 | 
			
		||||
        if (this.isInitialized) return;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const homeDir = os.homedir();
 | 
			
		||||
@ -285,21 +51,16 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
            
 | 
			
		||||
            // Windows에서는 .exe 확장자 필요
 | 
			
		||||
            const platform = this.getPlatform();
 | 
			
		||||
            const whisperExecutable = platform === 'win32' ? 'whisper-whisper.exe' : 'whisper';
 | 
			
		||||
            const whisperExecutable = platform === 'win32' ? 'whisper.exe' : 'whisper';
 | 
			
		||||
            this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable);
 | 
			
		||||
 | 
			
		||||
            await this.ensureDirectories();
 | 
			
		||||
            await this.ensureWhisperBinary();
 | 
			
		||||
            
 | 
			
		||||
            this.installState.isInitialized = true;
 | 
			
		||||
            this.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;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -310,56 +71,6 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
        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) {
 | 
			
		||||
@ -388,11 +99,6 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
            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);
 | 
			
		||||
@ -400,12 +106,6 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.autoInstall();
 | 
			
		||||
        
 | 
			
		||||
        // verify installation
 | 
			
		||||
        const verified = await this.verifyInstallation();
 | 
			
		||||
        if (!verified.success) {
 | 
			
		||||
            throw new Error(`Whisper installation verification failed: ${verified.error}`);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async installViaHomebrew() {
 | 
			
		||||
@ -432,7 +132,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    async ensureModelAvailable(modelId) {
 | 
			
		||||
        if (!this.installState.isInitialized) {
 | 
			
		||||
        if (!this.isInitialized) {
 | 
			
		||||
            console.log('[WhisperService] Service not initialized, initializing now...');
 | 
			
		||||
            await this.initialize();
 | 
			
		||||
        }
 | 
			
		||||
@ -457,37 +157,39 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
        const modelPath = await this.getModelPath(modelId);
 | 
			
		||||
        const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
 | 
			
		||||
        
 | 
			
		||||
        // Emit progress event - LocalAIManager가 처리
 | 
			
		||||
        this.emit('install-progress', { 
 | 
			
		||||
            model: modelId, 
 | 
			
		||||
            progress: 0 
 | 
			
		||||
        });
 | 
			
		||||
        this.emit('downloadProgress', { modelId, progress: 0 });
 | 
			
		||||
        
 | 
			
		||||
        await this.downloadWithRetry(modelInfo.url, modelPath, {
 | 
			
		||||
            expectedChecksum: checksumInfo?.sha256,
 | 
			
		||||
            modelId, // pass modelId to LocalAIServiceBase for event handling
 | 
			
		||||
            onProgress: (progress) => {
 | 
			
		||||
                // Emit progress event - LocalAIManager가 처리
 | 
			
		||||
                this.emit('install-progress', { 
 | 
			
		||||
                    model: modelId, 
 | 
			
		||||
                    progress 
 | 
			
		||||
                });
 | 
			
		||||
                this.emit('downloadProgress', { modelId, progress });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
 | 
			
		||||
        this.emit('model-download-complete', { modelId });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleDownloadModel(modelId) {
 | 
			
		||||
    async handleDownloadModel(event, modelId) {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[WhisperService] Handling download for model: ${modelId}`);
 | 
			
		||||
 | 
			
		||||
            if (!this.installState.isInitialized) {
 | 
			
		||||
            if (!this.isInitialized) {
 | 
			
		||||
                await this.initialize();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await this.ensureModelAvailable(modelId);
 | 
			
		||||
            const progressHandler = (data) => {
 | 
			
		||||
                if (data.modelId === modelId && event && event.sender) {
 | 
			
		||||
                    event.sender.send('whisper:download-progress', data);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            this.on('downloadProgress', progressHandler);
 | 
			
		||||
            
 | 
			
		||||
            try {
 | 
			
		||||
                await this.ensureModelAvailable(modelId);
 | 
			
		||||
            } finally {
 | 
			
		||||
                this.removeListener('downloadProgress', progressHandler);
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
@ -498,7 +200,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
    async handleGetInstalledModels() {
 | 
			
		||||
        try {
 | 
			
		||||
            if (!this.installState.isInitialized) {
 | 
			
		||||
            if (!this.isInitialized) {
 | 
			
		||||
                await this.initialize();
 | 
			
		||||
            }
 | 
			
		||||
            const models = await this.getInstalledModels();
 | 
			
		||||
@ -510,7 +212,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getModelPath(modelId) {
 | 
			
		||||
        if (!this.installState.isInitialized || !this.modelsDir) {
 | 
			
		||||
        if (!this.isInitialized || !this.modelsDir) {
 | 
			
		||||
            throw new Error('WhisperService is not initialized. Call initialize() first.');
 | 
			
		||||
        }
 | 
			
		||||
        return path.join(this.modelsDir, `${modelId}.bin`);
 | 
			
		||||
@ -535,7 +237,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
    createWavHeader(dataSize) {
 | 
			
		||||
        const header = Buffer.alloc(44);
 | 
			
		||||
        const sampleRate = 16000;
 | 
			
		||||
        const sampleRate = 24000;
 | 
			
		||||
        const numChannels = 1;
 | 
			
		||||
        const bitsPerSample = 16;
 | 
			
		||||
        
 | 
			
		||||
@ -584,7 +286,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getInstalledModels() {
 | 
			
		||||
        if (!this.installState.isInitialized) {
 | 
			
		||||
        if (!this.isInitialized) {
 | 
			
		||||
            console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...');
 | 
			
		||||
            await this.initialize();
 | 
			
		||||
        }
 | 
			
		||||
@ -613,11 +315,11 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async isServiceRunning() {
 | 
			
		||||
        return this.installState.isInitialized;
 | 
			
		||||
        return this.isInitialized;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async startService() {
 | 
			
		||||
        if (!this.installState.isInitialized) {
 | 
			
		||||
        if (!this.isInitialized) {
 | 
			
		||||
            await this.initialize();
 | 
			
		||||
        }
 | 
			
		||||
        return true;
 | 
			
		||||
@ -643,7 +345,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
    async installWindows() {
 | 
			
		||||
        console.log('[WhisperService] Installing Whisper on Windows...');
 | 
			
		||||
        const version = 'v1.7.6';
 | 
			
		||||
        const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-bin-x64.zip`;
 | 
			
		||||
        const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-x64.zip`;
 | 
			
		||||
        const tempFile = path.join(this.tempDir, 'whisper-binary.zip');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
@ -721,7 +423,8 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
                if (item.isDirectory()) {
 | 
			
		||||
                    const subExecutables = await this.findWhisperExecutables(fullPath);
 | 
			
		||||
                    executables.push(...subExecutables);
 | 
			
		||||
                } else if (item.isFile() && (item.name === 'whisper-whisper.exe' || item.name === 'whisper.exe' || item.name === 'main.exe')) {
 | 
			
		||||
                } else if (item.isFile() && (item.name === 'whisper.exe' || item.name === 'main.exe')) {
 | 
			
		||||
                    // main.exe도 포함 (일부 빌드에서 whisper 실행파일이 main.exe로 명명됨)
 | 
			
		||||
                    executables.push(fullPath);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
@ -756,7 +459,7 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
    async installLinux() {
 | 
			
		||||
        console.log('[WhisperService] Installing Whisper on Linux...');
 | 
			
		||||
        const version = 'v1.7.6';
 | 
			
		||||
        const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
 | 
			
		||||
        const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
 | 
			
		||||
        const tempFile = path.join(this.tempDir, 'whisper-binary.tar.gz');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
@ -786,92 +489,6 @@ class WhisperService extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// 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;
 | 
			
		||||
@ -4,7 +4,6 @@ const SummaryService = require('./summary/summaryService');
 | 
			
		||||
const authService = require('../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../common/repositories/session');
 | 
			
		||||
const sttRepository = require('./stt/repositories');
 | 
			
		||||
const internalBridge = require('../../bridge/internalBridge');
 | 
			
		||||
 | 
			
		||||
class ListenService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
@ -40,12 +39,11 @@ class ListenService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        const { windowPool } = require('../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool?.get('listen');
 | 
			
		||||
        
 | 
			
		||||
        if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
            listenWindow.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize() {
 | 
			
		||||
@ -54,7 +52,7 @@ class ListenService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleListenRequest(listenButtonText) {
 | 
			
		||||
        const { windowPool } = require('../../window/windowManager');
 | 
			
		||||
        const { windowPool, updateLayout } = require('../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool.get('listen');
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
 | 
			
		||||
@ -62,24 +60,22 @@ class ListenService {
 | 
			
		||||
            switch (listenButtonText) {
 | 
			
		||||
                case 'Listen':
 | 
			
		||||
                    console.log('[ListenService] changeSession to "Listen"');
 | 
			
		||||
                    internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true });
 | 
			
		||||
                    listenWindow.show();
 | 
			
		||||
                    updateLayout();
 | 
			
		||||
                    listenWindow.webContents.send('window-show-animation');
 | 
			
		||||
                    await this.initializeSession();
 | 
			
		||||
                    if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
                        listenWindow.webContents.send('session-state-changed', { isActive: true });
 | 
			
		||||
                    }
 | 
			
		||||
                    listenWindow.webContents.send('session-state-changed', { isActive: true });
 | 
			
		||||
                    break;
 | 
			
		||||
        
 | 
			
		||||
                case 'Stop':
 | 
			
		||||
                    console.log('[ListenService] changeSession to "Stop"');
 | 
			
		||||
                    await this.closeSession();
 | 
			
		||||
                    if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
                        listenWindow.webContents.send('session-state-changed', { isActive: false });
 | 
			
		||||
                    }
 | 
			
		||||
                    listenWindow.webContents.send('session-state-changed', { isActive: false });
 | 
			
		||||
                    break;
 | 
			
		||||
        
 | 
			
		||||
                case 'Done':
 | 
			
		||||
                    console.log('[ListenService] changeSession to "Done"');
 | 
			
		||||
                    internalBridge.emit('window:requestVisibility', { name: 'listen', visible: false });
 | 
			
		||||
                    listenWindow.webContents.send('window-hide-animation');
 | 
			
		||||
                    listenWindow.webContents.send('session-state-changed', { isActive: false });
 | 
			
		||||
                    break;
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ const { BrowserWindow } = require('electron');
 | 
			
		||||
const { spawn } = require('child_process');
 | 
			
		||||
const { createSTT } = require('../../common/ai/factory');
 | 
			
		||||
const modelStateService = require('../../common/services/modelStateService');
 | 
			
		||||
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager');
 | 
			
		||||
 | 
			
		||||
const COMPLETION_DEBOUNCE_MS = 2000;
 | 
			
		||||
 | 
			
		||||
@ -34,13 +35,11 @@ class SttService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        // Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
 | 
			
		||||
        const { windowPool } = require('../../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool?.get('listen');
 | 
			
		||||
        
 | 
			
		||||
        if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
            listenWindow.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleSendSystemAudioContent(data, mimeType) {
 | 
			
		||||
@ -133,7 +132,7 @@ class SttService {
 | 
			
		||||
    async initializeSttSessions(language = 'en') {
 | 
			
		||||
        const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
 | 
			
		||||
 | 
			
		||||
        const modelInfo = await modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        const modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
            throw new Error('AI model or API key is not configured.');
 | 
			
		||||
        }
 | 
			
		||||
@ -145,7 +144,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
 | 
			
		||||
@ -166,6 +165,10 @@ class SttService {
 | 
			
		||||
                        '(NOISE)'
 | 
			
		||||
                    ];
 | 
			
		||||
                    
 | 
			
		||||
 | 
			
		||||
                    
 | 
			
		||||
                    const normalizedText = finalText.toLowerCase().trim();
 | 
			
		||||
                    
 | 
			
		||||
                    const isNoise = noisePatterns.some(pattern => 
 | 
			
		||||
                        finalText.includes(pattern) || finalText === pattern
 | 
			
		||||
                    );
 | 
			
		||||
@ -216,38 +219,6 @@ 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) || '';
 | 
			
		||||
@ -307,6 +278,9 @@ class SttService {
 | 
			
		||||
                        '(NOISE)'
 | 
			
		||||
                    ];
 | 
			
		||||
                    
 | 
			
		||||
                    
 | 
			
		||||
                    const normalizedText = finalText.toLowerCase().trim();
 | 
			
		||||
                    
 | 
			
		||||
                    const isNoise = noisePatterns.some(pattern => 
 | 
			
		||||
                        finalText.includes(pattern) || finalText === pattern
 | 
			
		||||
                    );
 | 
			
		||||
@ -358,34 +332,6 @@ 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) || '';
 | 
			
		||||
@ -466,20 +412,16 @@ class SttService {
 | 
			
		||||
        let modelInfo = this.modelInfo;
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
 | 
			
		||||
            modelInfo = await modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
            modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            throw new Error('STT model info could not be retrieved.');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
        }
 | 
			
		||||
        const payload = modelInfo.provider === 'gemini'
 | 
			
		||||
            ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
 | 
			
		||||
            : data;
 | 
			
		||||
 | 
			
		||||
        await this.mySttSession.sendRealtimeInput(payload);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -491,21 +433,16 @@ class SttService {
 | 
			
		||||
        let modelInfo = this.modelInfo;
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
 | 
			
		||||
            modelInfo = await modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
            modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            throw new Error('STT model info could not be retrieved.');
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const payload = modelInfo.provider === 'gemini'
 | 
			
		||||
            ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
 | 
			
		||||
            : data;
 | 
			
		||||
        
 | 
			
		||||
        await this.theirSttSession.sendRealtimeInput(payload);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -577,7 +514,7 @@ class SttService {
 | 
			
		||||
        let modelInfo = this.modelInfo;
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
 | 
			
		||||
            modelInfo = await modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
            modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            throw new Error('STT model info could not be retrieved.');
 | 
			
		||||
@ -597,15 +534,9 @@ class SttService {
 | 
			
		||||
 | 
			
		||||
                if (this.theirSttSession) {
 | 
			
		||||
                    try {
 | 
			
		||||
                        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;
 | 
			
		||||
                        }
 | 
			
		||||
 | 
			
		||||
                        const payload = modelInfo.provider === 'gemini'
 | 
			
		||||
                            ? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
 | 
			
		||||
                            : base64Data;
 | 
			
		||||
                        await this.theirSttSession.sendRealtimeInput(payload);
 | 
			
		||||
                    } catch (err) {
 | 
			
		||||
                        console.error('Error sending system audio:', err.message);
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,7 @@ const { createLLM } = require('../../common/ai/factory');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
const summaryRepository = require('./repositories');
 | 
			
		||||
const modelStateService = require('../../common/services/modelStateService');
 | 
			
		||||
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js');
 | 
			
		||||
 | 
			
		||||
class SummaryService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
@ -27,12 +28,11 @@ class SummaryService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        const { windowPool } = require('../../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool?.get('listen');
 | 
			
		||||
        
 | 
			
		||||
        if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
            listenWindow.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addConversationTurn(speaker, text) {
 | 
			
		||||
@ -98,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments.
 | 
			
		||||
                await sessionRepository.touch(this.currentSessionId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const modelInfo = await modelStateService.getCurrentModelInfo('llm');
 | 
			
		||||
            const modelInfo = modelStateService.getCurrentModelInfo('llm');
 | 
			
		||||
            if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
                throw new Error('AI model or API key is not configured.');
 | 
			
		||||
            }
 | 
			
		||||
@ -304,20 +304,25 @@ Keep all points concise and build upon previous analysis if provided.`,
 | 
			
		||||
     */
 | 
			
		||||
    async triggerAnalysisIfNeeded() {
 | 
			
		||||
        if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
 | 
			
		||||
            console.log(`Triggering analysis - ${this.conversationHistory.length} conversation texts accumulated`);
 | 
			
		||||
            console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`);
 | 
			
		||||
 | 
			
		||||
            const data = await this.makeOutlineAndRequests(this.conversationHistory);
 | 
			
		||||
            if (data) {
 | 
			
		||||
                console.log('Sending structured data to renderer');
 | 
			
		||||
                this.sendToRenderer('summary-update', data);
 | 
			
		||||
                
 | 
			
		||||
                // Notify callback
 | 
			
		||||
                if (this.onAnalysisComplete) {
 | 
			
		||||
                    this.onAnalysisComplete(data);
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log('No analysis data returned');
 | 
			
		||||
            }
 | 
			
		||||
            this.makeOutlineAndRequests(this.conversationHistory)
 | 
			
		||||
                .then(data => {
 | 
			
		||||
                    if (data) {
 | 
			
		||||
                        console.log('📤 Sending structured data to renderer');
 | 
			
		||||
                        this.sendToRenderer('summary-update', data);
 | 
			
		||||
                        
 | 
			
		||||
                        // Notify callback
 | 
			
		||||
                        if (this.onAnalysisComplete) {
 | 
			
		||||
                            this.onAnalysisComplete(data);
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        console.log('❌ No analysis data returned from non-blocking call');
 | 
			
		||||
                    }
 | 
			
		||||
                })
 | 
			
		||||
                .catch(error => {
 | 
			
		||||
                    console.error('❌ Error in non-blocking analysis:', error);
 | 
			
		||||
                });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,8 @@ const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window
 | 
			
		||||
 | 
			
		||||
// New imports for common services
 | 
			
		||||
const modelStateService = require('../common/services/modelStateService');
 | 
			
		||||
const localAIManager = require('../common/services/localAIManager');
 | 
			
		||||
const ollamaService = require('../common/services/ollamaService');
 | 
			
		||||
const whisperService = require('../common/services/whisperService');
 | 
			
		||||
 | 
			
		||||
const store = new Store({
 | 
			
		||||
    name: 'pickle-glass-settings',
 | 
			
		||||
@ -26,14 +27,13 @@ const NOTIFICATION_CONFIG = {
 | 
			
		||||
// New facade functions for model state management
 | 
			
		||||
async function getModelSettings() {
 | 
			
		||||
    try {
 | 
			
		||||
        const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([
 | 
			
		||||
        const [config, storedKeys, availableLlm, availableStt, selectedModels] = await Promise.all([
 | 
			
		||||
            modelStateService.getProviderConfig(),
 | 
			
		||||
            modelStateService.getAllApiKeys(),
 | 
			
		||||
            modelStateService.getSelectedModels(),
 | 
			
		||||
            modelStateService.getAvailableModels('llm'),
 | 
			
		||||
            modelStateService.getAvailableModels('stt')
 | 
			
		||||
            modelStateService.getAvailableModels('stt'),
 | 
			
		||||
            modelStateService.getSelectedModels(),
 | 
			
		||||
        ]);
 | 
			
		||||
        
 | 
			
		||||
        return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[SettingsService] Error getting model settings:', error);
 | 
			
		||||
@ -41,6 +41,10 @@ async function getModelSettings() {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function validateAndSaveKey(provider, key) {
 | 
			
		||||
    return modelStateService.handleValidateKey(provider, key);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function clearApiKey(provider) {
 | 
			
		||||
    const success = await modelStateService.handleRemoveApiKey(provider);
 | 
			
		||||
    return { success };
 | 
			
		||||
@ -51,21 +55,17 @@ async function setSelectedModel(type, modelId) {
 | 
			
		||||
    return { success };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// LocalAI facade functions
 | 
			
		||||
// Ollama facade functions
 | 
			
		||||
async function getOllamaStatus() {
 | 
			
		||||
    return localAIManager.getServiceStatus('ollama');
 | 
			
		||||
    return ollamaService.getStatus();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function ensureOllamaReady() {
 | 
			
		||||
    const status = await localAIManager.getServiceStatus('ollama');
 | 
			
		||||
    if (!status.installed || !status.running) {
 | 
			
		||||
        await localAIManager.startService('ollama');
 | 
			
		||||
    }
 | 
			
		||||
    return { success: true };
 | 
			
		||||
    return ollamaService.ensureReady();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function shutdownOllama() {
 | 
			
		||||
    return localAIManager.stopService('ollama');
 | 
			
		||||
    return ollamaService.shutdown(false); // false for graceful shutdown
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -458,6 +458,7 @@ module.exports = {
 | 
			
		||||
    setAutoUpdateSetting,
 | 
			
		||||
    // Model settings facade
 | 
			
		||||
    getModelSettings,
 | 
			
		||||
    validateAndSaveKey,
 | 
			
		||||
    clearApiKey,
 | 
			
		||||
    setSelectedModel,
 | 
			
		||||
    // Ollama facade
 | 
			
		||||
 | 
			
		||||
@ -8,11 +8,12 @@ class ShortcutsService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.lastVisibleWindows = new Set(['header']);
 | 
			
		||||
        this.mouseEventsIgnored = false;
 | 
			
		||||
        this.movementManager = null;
 | 
			
		||||
        this.windowPool = null;
 | 
			
		||||
        this.allWindowVisibility = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize(windowPool) {
 | 
			
		||||
    initialize(movementManager, windowPool) {
 | 
			
		||||
        this.movementManager = movementManager;
 | 
			
		||||
        this.windowPool = windowPool;
 | 
			
		||||
        internalBridge.on('reregister-shortcuts', () => {
 | 
			
		||||
            console.log('[ShortcutsService] Reregistering shortcuts due to header state change.');
 | 
			
		||||
@ -21,41 +22,6 @@ 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 {
 | 
			
		||||
@ -106,6 +72,32 @@ 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) {
 | 
			
		||||
@ -120,23 +112,39 @@ class ShortcutsService {
 | 
			
		||||
        console.log(`[Shortcuts] Saved keybinds.`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async toggleAllWindowsVisibility() {
 | 
			
		||||
        const targetVisibility = !this.allWindowVisibility;
 | 
			
		||||
        internalBridge.emit('window:requestToggleAllWindowsVisibility', {
 | 
			
		||||
            targetVisibility: targetVisibility
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        if (this.allWindowVisibility) {
 | 
			
		||||
            await this.registerShortcuts(true);
 | 
			
		||||
        } else {
 | 
			
		||||
            await this.registerShortcuts();
 | 
			
		||||
    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.allWindowVisibility = !this.allWindowVisibility;
 | 
			
		||||
      
 | 
			
		||||
        this.lastVisibleWindows.forEach(name => {
 | 
			
		||||
            const win = windowPool.get(name);
 | 
			
		||||
            if (win && !win.isDestroyed()) {
 | 
			
		||||
                win.show();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async registerShortcuts(registerOnlyToggleVisibility = false) {
 | 
			
		||||
        if (!this.windowPool) {
 | 
			
		||||
    async registerShortcuts() {
 | 
			
		||||
        if (!this.movementManager || !this.windowPool) {
 | 
			
		||||
            console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
@ -160,14 +168,6 @@ 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';
 | 
			
		||||
@ -177,7 +177,7 @@ class ShortcutsService {
 | 
			
		||||
        if (displays.length > 1) {
 | 
			
		||||
            displays.forEach((display, index) => {
 | 
			
		||||
                const key = `${modifier}+Shift+${index + 1}`;
 | 
			
		||||
                globalShortcut.register(key, () => internalBridge.emit('window:moveToDisplay', { displayId: display.id }));
 | 
			
		||||
                globalShortcut.register(key, () => this.movementManager.moveToDisplay(display.id));
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
@ -188,14 +188,14 @@ class ShortcutsService {
 | 
			
		||||
        ];
 | 
			
		||||
        edgeDirections.forEach(({ key, direction }) => {
 | 
			
		||||
            globalShortcut.register(key, () => {
 | 
			
		||||
                if (header && header.isVisible()) internalBridge.emit('window:moveToEdge', { direction });
 | 
			
		||||
                if (header && header.isVisible()) this.movementManager.moveToEdge(direction);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // --- User-configurable shortcuts ---
 | 
			
		||||
        if (header?.currentHeaderState === 'apikey') {
 | 
			
		||||
            if (keybinds.toggleVisibility) {
 | 
			
		||||
                globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
 | 
			
		||||
                globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility(this.windowPool));
 | 
			
		||||
            }
 | 
			
		||||
            console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.');
 | 
			
		||||
            return;
 | 
			
		||||
@ -208,10 +208,10 @@ class ShortcutsService {
 | 
			
		||||
            let callback;
 | 
			
		||||
            switch(action) {
 | 
			
		||||
                case 'toggleVisibility':
 | 
			
		||||
                    callback = () => this.toggleAllWindowsVisibility();
 | 
			
		||||
                    callback = () => this.toggleAllWindowsVisibility(this.windowPool);
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'nextStep':
 | 
			
		||||
                    callback = () => askService.toggleAskButton(true);
 | 
			
		||||
                    callback = () => askService.toggleAskButton();
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'scrollUp':
 | 
			
		||||
                    callback = () => {
 | 
			
		||||
@ -230,16 +230,16 @@ class ShortcutsService {
 | 
			
		||||
                    };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveUp':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'up' }); };
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('up'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveDown':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'down' }); };
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('down'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveLeft':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'left' }); };
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('left'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveRight':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'right' }); };
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('right'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'toggleClickThrough':
 | 
			
		||||
                     callback = () => {
 | 
			
		||||
@ -282,7 +282,4 @@ class ShortcutsService {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const shortcutsService = new ShortcutsService();
 | 
			
		||||
 | 
			
		||||
module.exports = shortcutsService;
 | 
			
		||||
module.exports = new ShortcutsService(); 
 | 
			
		||||
							
								
								
									
										82
									
								
								src/index.js
									
									
									
									
									
								
							
							
						
						
									
										82
									
								
								src/index.js
									
									
									
									
									
								
							@ -686,43 +686,75 @@ async function startWebStack() {
 | 
			
		||||
 | 
			
		||||
  console.log(`✅ API server started on http://localhost:${apiPort}`);
 | 
			
		||||
 | 
			
		||||
  console.log(`🚀 All services ready:
 | 
			
		||||
   Frontend: http://localhost:${frontendPort}
 | 
			
		||||
   API:      http://localhost:${apiPort}`);
 | 
			
		||||
  console.log(`🚀 All services ready:`);
 | 
			
		||||
  console.log(`   Frontend: http://localhost:${frontendPort}`);
 | 
			
		||||
  console.log(`   API:      http://localhost:${apiPort}`);
 | 
			
		||||
 | 
			
		||||
  return frontendPort;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Auto-update initialization
 | 
			
		||||
async function initAutoUpdater() {
 | 
			
		||||
    if (process.env.NODE_ENV === 'development') {
 | 
			
		||||
        console.log('Development environment, skipping auto-updater.');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        await autoUpdater.checkForUpdates();
 | 
			
		||||
        autoUpdater.on('update-available', () => {
 | 
			
		||||
            console.log('Update available!');
 | 
			
		||||
            autoUpdater.downloadUpdate();
 | 
			
		||||
        const autoUpdateEnabled = await settingsService.getAutoUpdateSetting();
 | 
			
		||||
        if (!autoUpdateEnabled) {
 | 
			
		||||
            console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        // Skip auto-updater in development mode
 | 
			
		||||
        if (!app.isPackaged) {
 | 
			
		||||
            console.log('[AutoUpdater] Skipped in development (app is not packaged)');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        autoUpdater.setFeedURL({
 | 
			
		||||
            provider: 'github',
 | 
			
		||||
            owner: 'pickle-com',
 | 
			
		||||
            repo: 'glass',
 | 
			
		||||
        });
 | 
			
		||||
        autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => {
 | 
			
		||||
            console.log('Update downloaded:', releaseNotes, releaseName, date, url);
 | 
			
		||||
            dialog.showMessageBox({
 | 
			
		||||
 | 
			
		||||
        // Immediately check for updates & notify
 | 
			
		||||
        autoUpdater.checkForUpdatesAndNotify()
 | 
			
		||||
            .catch(err => {
 | 
			
		||||
                console.error('[AutoUpdater] Error checking for updates:', err);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
        autoUpdater.on('checking-for-update', () => {
 | 
			
		||||
            console.log('[AutoUpdater] Checking for updates…');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        autoUpdater.on('update-available', (info) => {
 | 
			
		||||
            console.log('[AutoUpdater] Update available:', info.version);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        autoUpdater.on('update-not-available', () => {
 | 
			
		||||
            console.log('[AutoUpdater] Application is up-to-date');
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        autoUpdater.on('error', (err) => {
 | 
			
		||||
            console.error('[AutoUpdater] Error while updating:', err);
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        autoUpdater.on('update-downloaded', (info) => {
 | 
			
		||||
            console.log(`[AutoUpdater] Update downloaded: ${info.version}`);
 | 
			
		||||
 | 
			
		||||
            const dialogOpts = {
 | 
			
		||||
                type: 'info',
 | 
			
		||||
                title: 'Application Update',
 | 
			
		||||
                message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`,
 | 
			
		||||
                buttons: ['Restart', 'Later']
 | 
			
		||||
            }).then(response => {
 | 
			
		||||
                if (response.response === 0) {
 | 
			
		||||
                buttons: ['Install now', 'Install on next launch'],
 | 
			
		||||
                title: 'Update Available',
 | 
			
		||||
                message: 'A new version of Glass is ready to be installed.',
 | 
			
		||||
                defaultId: 0,
 | 
			
		||||
                cancelId: 1
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            dialog.showMessageBox(dialogOpts).then((returnValue) => {
 | 
			
		||||
                // returnValue.response 0 is for 'Install Now'
 | 
			
		||||
                if (returnValue.response === 0) {
 | 
			
		||||
                    autoUpdater.quitAndInstall();
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
        autoUpdater.on('error', (err) => {
 | 
			
		||||
            console.error('Error in auto-updater:', err);
 | 
			
		||||
        });
 | 
			
		||||
    } catch (err) {
 | 
			
		||||
        console.error('Error initializing auto-updater:', err);
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
        console.error('[AutoUpdater] Failed to initialise:', e);
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										103
									
								
								src/preload.js
									
									
									
									
									
								
							
							
						
						
									
										103
									
								
								src/preload.js
									
									
									
									
									
								
							@ -19,7 +19,6 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    
 | 
			
		||||
    // App Control
 | 
			
		||||
      quitApplication: () => ipcRenderer.invoke('quit-application'),
 | 
			
		||||
      openExternal: (url) => ipcRenderer.invoke('open-external', url),
 | 
			
		||||
 | 
			
		||||
    // User state listener (used by multiple components)
 | 
			
		||||
      onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
 | 
			
		||||
@ -31,20 +30,11 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
  apiKeyHeader: {
 | 
			
		||||
    // Model & Provider Management
 | 
			
		||||
    getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
 | 
			
		||||
    // 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'),
 | 
			
		||||
    getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
 | 
			
		||||
    getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
 | 
			
		||||
    ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
 | 
			
		||||
    installOllama: () => ipcRenderer.invoke('localai:install', { service: 'ollama' }),
 | 
			
		||||
    startOllamaService: () => ipcRenderer.invoke('localai:start-service', 'ollama'),
 | 
			
		||||
    installOllama: () => ipcRenderer.invoke('ollama:install'),
 | 
			
		||||
    startOllamaService: () => ipcRenderer.invoke('ollama:start-service'),
 | 
			
		||||
    pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
 | 
			
		||||
    downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
 | 
			
		||||
    validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
 | 
			
		||||
@ -56,25 +46,21 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    // 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),
 | 
			
		||||
    
 | 
			
		||||
    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),
 | 
			
		||||
 | 
			
		||||
    // Remove all listeners (for cleanup)
 | 
			
		||||
    removeAllListeners: () => {
 | 
			
		||||
      // 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');
 | 
			
		||||
      ipcRenderer.removeAllListeners('whisper:download-progress');
 | 
			
		||||
      ipcRenderer.removeAllListeners('ollama:install-progress');
 | 
			
		||||
      ipcRenderer.removeAllListeners('ollama:pull-progress');
 | 
			
		||||
      ipcRenderer.removeAllListeners('ollama:install-complete');
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
@ -82,7 +68,6 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
  headerController: {
 | 
			
		||||
    // State Management
 | 
			
		||||
    sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
 | 
			
		||||
    reInitializeModelState: () => ipcRenderer.invoke('model:re-initialize-state'),
 | 
			
		||||
    
 | 
			
		||||
    // Window Management
 | 
			
		||||
    resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
 | 
			
		||||
@ -97,7 +82,7 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback),
 | 
			
		||||
    removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback),
 | 
			
		||||
    onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback),
 | 
			
		||||
    removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback),
 | 
			
		||||
    removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/MainHeader.js
 | 
			
		||||
@ -109,14 +94,11 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
 | 
			
		||||
    // Settings Window Management
 | 
			
		||||
    cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
 | 
			
		||||
    showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
 | 
			
		||||
    showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds),
 | 
			
		||||
    hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
 | 
			
		||||
    
 | 
			
		||||
    // Generic invoke (for dynamic channel names)
 | 
			
		||||
    // invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
 | 
			
		||||
    sendListenButtonClick: (listenButtonText) => ipcRenderer.invoke('listen:changeSession', listenButtonText),
 | 
			
		||||
    sendAskButtonClick: () => ipcRenderer.invoke('ask:toggleAskButton'),
 | 
			
		||||
    sendToggleAllWindowsVisibility: () => ipcRenderer.invoke('shortcut:toggleAllWindowsVisibility'),
 | 
			
		||||
    invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback),
 | 
			
		||||
@ -131,9 +113,7 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
 | 
			
		||||
    requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
 | 
			
		||||
    openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
 | 
			
		||||
    markKeychainCompleted: () => ipcRenderer.invoke('mark-keychain-completed'),
 | 
			
		||||
    checkKeychainCompleted: (uid) => ipcRenderer.invoke('check-keychain-completed', uid),
 | 
			
		||||
    initializeEncryptionKey: () => ipcRenderer.invoke('initialize-encryption-key') // New for keychain
 | 
			
		||||
    markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed')
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/PickleGlassApp.js
 | 
			
		||||
@ -148,7 +128,7 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
  askView: {
 | 
			
		||||
    // Window Management
 | 
			
		||||
    closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
 | 
			
		||||
    adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
 | 
			
		||||
    adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
 | 
			
		||||
    
 | 
			
		||||
    // Message Handling
 | 
			
		||||
    sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
 | 
			
		||||
@ -157,9 +137,6 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
 | 
			
		||||
    removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback),
 | 
			
		||||
 | 
			
		||||
    onAskStreamError: (callback) => ipcRenderer.on('ask-response-stream-error', callback),
 | 
			
		||||
    removeOnAskStreamError: (callback) => ipcRenderer.removeListener('ask-response-stream-error', callback),
 | 
			
		||||
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
 | 
			
		||||
    removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback),
 | 
			
		||||
@ -173,7 +150,7 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
  // src/ui/listen/ListenView.js
 | 
			
		||||
  listenView: {
 | 
			
		||||
    // Window Management
 | 
			
		||||
    adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
 | 
			
		||||
    adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
 | 
			
		||||
@ -232,8 +209,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('settings:getCurrentShortcuts'),
 | 
			
		||||
    openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'),
 | 
			
		||||
    getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'),
 | 
			
		||||
    openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'),
 | 
			
		||||
    
 | 
			
		||||
    // Window Management
 | 
			
		||||
    moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
 | 
			
		||||
@ -255,30 +232,40 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
 | 
			
		||||
    onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
 | 
			
		||||
    removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', 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)
 | 
			
		||||
    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)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/settings/ShortCutSettingsView.js
 | 
			
		||||
  shortcutSettingsView: {
 | 
			
		||||
    // Shortcut Management
 | 
			
		||||
    saveShortcuts: (shortcuts) => ipcRenderer.invoke('shortcut:saveShortcuts', shortcuts),
 | 
			
		||||
    getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'),
 | 
			
		||||
    closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'),
 | 
			
		||||
    saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts),
 | 
			
		||||
    getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'),
 | 
			
		||||
    closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback),
 | 
			
		||||
    removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback)
 | 
			
		||||
    onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback),
 | 
			
		||||
    removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/content.html inline scripts
 | 
			
		||||
  content: {
 | 
			
		||||
    // Animation Management
 | 
			
		||||
    sendAnimationFinished: () => ipcRenderer.send('animation-finished'),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onWindowShowAnimation: (callback) => ipcRenderer.on('window-show-animation', callback),
 | 
			
		||||
    removeOnWindowShowAnimation: (callback) => ipcRenderer.removeListener('window-show-animation', callback),
 | 
			
		||||
    onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback),
 | 
			
		||||
    removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback),
 | 
			
		||||
    onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
 | 
			
		||||
    removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),    
 | 
			
		||||
    removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),
 | 
			
		||||
    onListenWindowMoveToCenter: (callback) => ipcRenderer.on('listen-window-move-to-center', callback),
 | 
			
		||||
    removeOnListenWindowMoveToCenter: (callback) => ipcRenderer.removeListener('listen-window-move-to-center', callback),
 | 
			
		||||
    onListenWindowMoveToLeft: (callback) => ipcRenderer.on('listen-window-move-to-left', callback),
 | 
			
		||||
    removeOnListenWindowMoveToLeft: (callback) => ipcRenderer.removeListener('listen-window-move-to-left', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/listen/audioCore/listenCapture.js
 | 
			
		||||
@ -290,7 +277,7 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
 | 
			
		||||
    
 | 
			
		||||
    // Session Management
 | 
			
		||||
    isSessionActive: () => ipcRenderer.invoke('listen:isSessionActive'),
 | 
			
		||||
    isSessionActive: () => ipcRenderer.invoke('is-session-active'),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -1,20 +1,18 @@
 | 
			
		||||
import './MainHeader.js';
 | 
			
		||||
import './ApiKeyHeader.js';
 | 
			
		||||
import './PermissionHeader.js';
 | 
			
		||||
import './WelcomeHeader.js';
 | 
			
		||||
 | 
			
		||||
class HeaderTransitionManager {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.headerContainer      = document.getElementById('header-container');
 | 
			
		||||
        this.currentHeaderType    = null;   // 'welcome' | 'apikey' | 'main' | 'permission'
 | 
			
		||||
        this.welcomeHeader        = null;
 | 
			
		||||
        this.currentHeaderType    = null;   // 'apikey' | 'main' | 'permission'
 | 
			
		||||
        this.apiKeyHeader         = null;
 | 
			
		||||
        this.mainHeader            = null;
 | 
			
		||||
        this.permissionHeader      = null;
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * only one header window is allowed
 | 
			
		||||
         * @param {'welcome'|'apikey'|'main'|'permission'} type
 | 
			
		||||
         * @param {'apikey'|'main'|'permission'} type
 | 
			
		||||
         */
 | 
			
		||||
        this.ensureHeader = (type) => {
 | 
			
		||||
            console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
 | 
			
		||||
@ -25,39 +23,19 @@ class HeaderTransitionManager {
 | 
			
		||||
 | 
			
		||||
            this.headerContainer.innerHTML = '';
 | 
			
		||||
            
 | 
			
		||||
            this.welcomeHeader = null;
 | 
			
		||||
            this.apiKeyHeader = null;
 | 
			
		||||
            this.mainHeader = null;
 | 
			
		||||
            this.permissionHeader = null;
 | 
			
		||||
 | 
			
		||||
            // Create new header element
 | 
			
		||||
            if (type === 'welcome') {
 | 
			
		||||
                this.welcomeHeader = document.createElement('welcome-header');
 | 
			
		||||
                this.welcomeHeader.loginCallback = () => this.handleLoginOption();
 | 
			
		||||
                this.welcomeHeader.apiKeyCallback = () => this.handleApiKeyOption();
 | 
			
		||||
                this.headerContainer.appendChild(this.welcomeHeader);
 | 
			
		||||
                console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
 | 
			
		||||
            } else if (type === 'apikey') {
 | 
			
		||||
            if (type === 'apikey') {
 | 
			
		||||
                this.apiKeyHeader = document.createElement('apikey-header');
 | 
			
		||||
                this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
 | 
			
		||||
                this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
 | 
			
		||||
                this.apiKeyHeader.addEventListener('request-resize', e => {
 | 
			
		||||
                    this._resizeForApiKey(e.detail.height); 
 | 
			
		||||
                });
 | 
			
		||||
                this.headerContainer.appendChild(this.apiKeyHeader);
 | 
			
		||||
                console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
 | 
			
		||||
            } else if (type === 'permission') {
 | 
			
		||||
                this.permissionHeader = document.createElement('permission-setup');
 | 
			
		||||
                this.permissionHeader.addEventListener('request-resize', e => {
 | 
			
		||||
                    this._resizeForPermissionHeader(e.detail.height); 
 | 
			
		||||
                });
 | 
			
		||||
                this.permissionHeader.continueCallback = async () => {
 | 
			
		||||
                    if (window.api && window.api.headerController) {
 | 
			
		||||
                        console.log('[HeaderController] Re-initializing model state after permission grant...');
 | 
			
		||||
                        await window.api.headerController.reInitializeModelState();
 | 
			
		||||
                    }
 | 
			
		||||
                    this.transitionToMainHeader();
 | 
			
		||||
                };
 | 
			
		||||
                this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
 | 
			
		||||
                this.headerContainer.appendChild(this.permissionHeader);
 | 
			
		||||
            } else {
 | 
			
		||||
                this.mainHeader = document.createElement('main-header');
 | 
			
		||||
@ -71,10 +49,6 @@ class HeaderTransitionManager {
 | 
			
		||||
 | 
			
		||||
        console.log('[HeaderController] Manager initialized');
 | 
			
		||||
 | 
			
		||||
        // WelcomeHeader 콜백 메서드들
 | 
			
		||||
        this.handleLoginOption = this.handleLoginOption.bind(this);
 | 
			
		||||
        this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
 | 
			
		||||
 | 
			
		||||
        this._bootstrap();
 | 
			
		||||
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
@ -92,14 +66,8 @@ class HeaderTransitionManager {
 | 
			
		||||
            });
 | 
			
		||||
            window.api.headerController.onForceShowApiKeyHeader(async () => {
 | 
			
		||||
                console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
 | 
			
		||||
                const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
 | 
			
		||||
                if (!isConfigured) {
 | 
			
		||||
                    await this._resizeForWelcome();
 | 
			
		||||
                    this.ensureHeader('welcome');
 | 
			
		||||
                } else {
 | 
			
		||||
                    await this._resizeForApiKey();
 | 
			
		||||
                    this.ensureHeader('apikey');
 | 
			
		||||
                }
 | 
			
		||||
                await this._resizeForApiKey();
 | 
			
		||||
                this.ensureHeader('apikey');
 | 
			
		||||
            });            
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -120,7 +88,7 @@ class HeaderTransitionManager {
 | 
			
		||||
            this.handleStateUpdate(userState);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Fallback for non-electron environment (testing/web)
 | 
			
		||||
            this.ensureHeader('welcome');
 | 
			
		||||
            this.ensureHeader('apikey');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -130,46 +98,22 @@ class HeaderTransitionManager {
 | 
			
		||||
        const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
 | 
			
		||||
 | 
			
		||||
        if (isConfigured) {
 | 
			
		||||
            // If providers are configured, always check permissions regardless of login state.
 | 
			
		||||
            const permissionResult = await this.checkPermissions();
 | 
			
		||||
            if (permissionResult.success) {
 | 
			
		||||
                this.transitionToMainHeader();
 | 
			
		||||
            const { isLoggedIn } = userState;
 | 
			
		||||
            if (isLoggedIn) {
 | 
			
		||||
                const permissionResult = await this.checkPermissions();
 | 
			
		||||
                if (permissionResult.success) {
 | 
			
		||||
                    this.transitionToMainHeader();
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.transitionToPermissionHeader();
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                this.transitionToPermissionHeader();
 | 
			
		||||
                this.transitionToMainHeader();
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            // If no providers are configured, show the welcome header to prompt for setup.
 | 
			
		||||
            await this._resizeForWelcome();
 | 
			
		||||
            this.ensureHeader('welcome');
 | 
			
		||||
            await this._resizeForApiKey();
 | 
			
		||||
            this.ensureHeader('apikey');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // WelcomeHeader 콜백 메서드들
 | 
			
		||||
    async handleLoginOption() {
 | 
			
		||||
        console.log('[HeaderController] Login option selected');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            await window.api.common.startFirebaseAuth();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleApiKeyOption() {
 | 
			
		||||
        console.log('[HeaderController] API key option selected');
 | 
			
		||||
        await this._resizeForApiKey(400);
 | 
			
		||||
        this.ensureHeader('apikey');
 | 
			
		||||
        // ApiKeyHeader에 뒤로가기 콜백 설정
 | 
			
		||||
        if (this.apiKeyHeader) {
 | 
			
		||||
            this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async transitionToWelcomeHeader() {
 | 
			
		||||
        if (this.currentHeaderType === 'welcome') {
 | 
			
		||||
            return this._resizeForWelcome();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this._resizeForWelcome();
 | 
			
		||||
        this.ensureHeader('welcome');
 | 
			
		||||
    }
 | 
			
		||||
    //////// after_modelStateService ////////
 | 
			
		||||
 | 
			
		||||
    async transitionToPermissionHeader() {
 | 
			
		||||
@ -201,19 +145,7 @@ class HeaderTransitionManager {
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let initialHeight = 220;
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            try {
 | 
			
		||||
                const userState = await window.api.common.getCurrentUser();
 | 
			
		||||
                if (userState.mode === 'firebase') {
 | 
			
		||||
                    initialHeight = 280;
 | 
			
		||||
                }
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                console.error('Could not get user state for resize', e);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this._resizeForPermissionHeader(initialHeight);
 | 
			
		||||
        await this._resizeForPermissionHeader();
 | 
			
		||||
        this.ensureHeader('permission');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -229,26 +161,20 @@ class HeaderTransitionManager {
 | 
			
		||||
    async _resizeForMain() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        console.log('[HeaderController] _resizeForMain: Resizing window to 353x47');
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }).catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForApiKey(height = 370) {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        console.log(`[HeaderController] _resizeForApiKey: Resizing window to 456x${height}`);
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForPermissionHeader(height) {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        const finalHeight = height || 220;
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 285, height: finalHeight })
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForWelcome() {
 | 
			
		||||
    async _resizeForApiKey() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        console.log('[HeaderController] _resizeForWelcome: Resizing window to 456x370');
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 456, height: 364 })
 | 
			
		||||
        console.log('[HeaderController] _resizeForApiKey: Resizing window to 350x300');
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 350, height: 300 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForPermissionHeader() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ 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 },
 | 
			
		||||
@ -514,12 +515,30 @@ 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.showSettingsWindow();
 | 
			
		||||
            
 | 
			
		||||
            window.api.mainHeader.cancelHideSettingsWindow();
 | 
			
		||||
 | 
			
		||||
            if (element) {
 | 
			
		||||
                const { left, top, width, height } = element.getBoundingClientRect();
 | 
			
		||||
                window.api.mainHeader.showSettingsWindow({
 | 
			
		||||
                    x: left,
 | 
			
		||||
                    y: top,
 | 
			
		||||
                    width,
 | 
			
		||||
                    height,
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -540,10 +559,9 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        this.isTogglingSession = true;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const channel = 'listen:changeSession';
 | 
			
		||||
            const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                await window.api.mainHeader.sendListenButtonClick(listenButtonText);
 | 
			
		||||
            }
 | 
			
		||||
            await this.invoke(channel, listenButtonText);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('IPC invoke for session change failed:', error);
 | 
			
		||||
            this.isTogglingSession = false;
 | 
			
		||||
@ -554,26 +572,13 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        if (this.wasJustDragged) return;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                await window.api.mainHeader.sendAskButtonClick();
 | 
			
		||||
            }
 | 
			
		||||
            const channel = 'ask:toggleAskButton';
 | 
			
		||||
            await this.invoke(channel);
 | 
			
		||||
        } 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``;
 | 
			
		||||
@ -651,7 +656,7 @@ export class MainHeader extends LitElement {
 | 
			
		||||
                    </div>
 | 
			
		||||
                </div>
 | 
			
		||||
 | 
			
		||||
                <div class="header-actions" @click=${() => this._handleToggleAllWindowsVisibility()}>
 | 
			
		||||
                <div class="header-actions" @click=${() => this.invoke('toggle-all-windows-visibility')}>
 | 
			
		||||
                    <div class="action-text">
 | 
			
		||||
                        <div class="action-text-content">Show/Hide</div>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
        .container {
 | 
			
		||||
            -webkit-app-region: drag;
 | 
			
		||||
            width: 285px;
 | 
			
		||||
            /* height is now set dynamically */
 | 
			
		||||
            height: 220px;
 | 
			
		||||
            padding: 18px 20px;
 | 
			
		||||
            background: rgba(0, 0, 0, 0.3);
 | 
			
		||||
            border-radius: 16px;
 | 
			
		||||
@ -103,12 +103,6 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
            margin-top: auto;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .form-content.all-granted {
 | 
			
		||||
            flex-grow: 1;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            margin-top: 0;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .subtitle {
 | 
			
		||||
            color: rgba(255, 255, 255, 0.7);
 | 
			
		||||
            font-size: 11px;
 | 
			
		||||
@ -264,60 +258,24 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
    static properties = {
 | 
			
		||||
        microphoneGranted: { type: String },
 | 
			
		||||
        screenGranted: { type: String },
 | 
			
		||||
        keychainGranted: { type: String },
 | 
			
		||||
        isChecking: { type: String },
 | 
			
		||||
        continueCallback: { type: Function },
 | 
			
		||||
        userMode: { type: String }, // 'local' or 'firebase'
 | 
			
		||||
        continueCallback: { type: Function }
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.microphoneGranted = 'unknown';
 | 
			
		||||
        this.screenGranted = 'unknown';
 | 
			
		||||
        this.keychainGranted = 'unknown';
 | 
			
		||||
        this.isChecking = false;
 | 
			
		||||
        this.continueCallback = null;
 | 
			
		||||
        this.userMode = 'local'; // Default to local
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updated(changedProperties) {
 | 
			
		||||
        super.updated(changedProperties);
 | 
			
		||||
        if (changedProperties.has('userMode')) {
 | 
			
		||||
            const newHeight = this.userMode === 'firebase' ? 280 : 220;
 | 
			
		||||
            console.log(`[PermissionHeader] User mode changed to ${this.userMode}, requesting resize to ${newHeight}px`);
 | 
			
		||||
            this.dispatchEvent(new CustomEvent('request-resize', {
 | 
			
		||||
                detail: { height: newHeight },
 | 
			
		||||
                bubbles: true,
 | 
			
		||||
                composed: true
 | 
			
		||||
            }));
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async connectedCallback() {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            try {
 | 
			
		||||
                const userState = await window.api.common.getCurrentUser();
 | 
			
		||||
                this.userMode = userState.mode;
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                console.error('[PermissionHeader] Failed to get user state', e);
 | 
			
		||||
                this.userMode = 'local'; // Fallback to local
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.checkPermissions();
 | 
			
		||||
        
 | 
			
		||||
        // Set up periodic permission check
 | 
			
		||||
        this.permissionCheckInterval = setInterval(async () => {
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                try {
 | 
			
		||||
                    const userState = await window.api.common.getCurrentUser();
 | 
			
		||||
                    this.userMode = userState.mode;
 | 
			
		||||
                } catch (e) {
 | 
			
		||||
                    this.userMode = 'local';
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        this.permissionCheckInterval = setInterval(() => {
 | 
			
		||||
            this.checkPermissions();
 | 
			
		||||
        }, 1000);
 | 
			
		||||
    }
 | 
			
		||||
@ -340,25 +298,19 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
            
 | 
			
		||||
            const prevMic = this.microphoneGranted;
 | 
			
		||||
            const prevScreen = this.screenGranted;
 | 
			
		||||
            const prevKeychain = this.keychainGranted;
 | 
			
		||||
            
 | 
			
		||||
            this.microphoneGranted = permissions.microphone;
 | 
			
		||||
            this.screenGranted = permissions.screen;
 | 
			
		||||
            this.keychainGranted = permissions.keychain;
 | 
			
		||||
            
 | 
			
		||||
            // if permissions changed == UI update
 | 
			
		||||
            if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted || prevKeychain !== this.keychainGranted) {
 | 
			
		||||
            if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
 | 
			
		||||
                console.log('[PermissionHeader] Permission status changed, updating UI');
 | 
			
		||||
                this.requestUpdate();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const isKeychainRequired = this.userMode === 'firebase';
 | 
			
		||||
            const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
 | 
			
		||||
            
 | 
			
		||||
            // if all permissions granted == automatically continue
 | 
			
		||||
            if (this.microphoneGranted === 'granted' && 
 | 
			
		||||
                this.screenGranted === 'granted' && 
 | 
			
		||||
                keychainOk && 
 | 
			
		||||
                this.continueCallback) {
 | 
			
		||||
                console.log('[PermissionHeader] All permissions granted, proceeding automatically');
 | 
			
		||||
                setTimeout(() => this.handleContinue(), 500);
 | 
			
		||||
@ -429,39 +381,17 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleKeychainClick() {
 | 
			
		||||
        if (!window.api || this.keychainGranted === 'granted') return;
 | 
			
		||||
        
 | 
			
		||||
        console.log('[PermissionHeader] Requesting keychain permission...');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            // Trigger initializeKey to prompt for keychain access
 | 
			
		||||
            // Assuming encryptionService is accessible or via API
 | 
			
		||||
            await window.api.permissionHeader.initializeEncryptionKey(); // New IPC handler needed
 | 
			
		||||
            
 | 
			
		||||
            // After success, update status
 | 
			
		||||
            this.keychainGranted = 'granted';
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[PermissionHeader] Error requesting keychain permission:', error);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleContinue() {
 | 
			
		||||
        const isKeychainRequired = this.userMode === 'firebase';
 | 
			
		||||
        const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
 | 
			
		||||
 | 
			
		||||
        if (this.continueCallback && 
 | 
			
		||||
            this.microphoneGranted === 'granted' && 
 | 
			
		||||
            this.screenGranted === 'granted' && 
 | 
			
		||||
            keychainOk) {
 | 
			
		||||
            this.screenGranted === 'granted') {
 | 
			
		||||
            // Mark permissions as completed
 | 
			
		||||
            if (window.api && isKeychainRequired) {
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                try {
 | 
			
		||||
                    await window.api.permissionHeader.markKeychainCompleted();
 | 
			
		||||
                    console.log('[PermissionHeader] Marked keychain as completed');
 | 
			
		||||
                    await window.api.permissionHeader.markPermissionsCompleted();
 | 
			
		||||
                    console.log('[PermissionHeader] Marked permissions as completed');
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error('[PermissionHeader] Error marking keychain as completed:', error);
 | 
			
		||||
                    console.error('[PermissionHeader] Error marking permissions as completed:', error);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
@ -477,13 +407,10 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        const isKeychainRequired = this.userMode === 'firebase';
 | 
			
		||||
        const containerHeight = isKeychainRequired ? 280 : 220;
 | 
			
		||||
        const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
 | 
			
		||||
        const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && keychainOk;
 | 
			
		||||
        const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
 | 
			
		||||
 | 
			
		||||
        return html`
 | 
			
		||||
            <div class="container" style="height: ${containerHeight}px">
 | 
			
		||||
            <div class="container">
 | 
			
		||||
                <button class="close-button" @click=${this.handleClose} title="Close application">
 | 
			
		||||
                    <svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
 | 
			
		||||
                        <path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
 | 
			
		||||
@ -491,92 +418,65 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
                </button>
 | 
			
		||||
                <h1 class="title">Permission Setup Required</h1>
 | 
			
		||||
 | 
			
		||||
                <div class="form-content ${allGranted ? 'all-granted' : ''}">
 | 
			
		||||
                    ${!allGranted ? html`
 | 
			
		||||
                        <div class="subtitle">Grant access to microphone, screen recording${isKeychainRequired ? ' and keychain' : ''} to continue</div>
 | 
			
		||||
                        
 | 
			
		||||
                        <div class="permission-status">
 | 
			
		||||
                            <div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
 | 
			
		||||
                                ${this.microphoneGranted === 'granted' ? html`
 | 
			
		||||
                                    <svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
 | 
			
		||||
                                    </svg>
 | 
			
		||||
                                    <span>Microphone ✓</span>
 | 
			
		||||
                                ` : html`
 | 
			
		||||
                                    <svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                        <path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
 | 
			
		||||
                                    </svg>
 | 
			
		||||
                                    <span>Microphone</span>
 | 
			
		||||
                                `}
 | 
			
		||||
                            </div>
 | 
			
		||||
                            
 | 
			
		||||
                            <div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
 | 
			
		||||
                                ${this.screenGranted === 'granted' ? html`
 | 
			
		||||
                                    <svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                        <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
 | 
			
		||||
                                    </svg>
 | 
			
		||||
                                    <span>Screen ✓</span>
 | 
			
		||||
                                ` : html`
 | 
			
		||||
                                    <svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                        <path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
 | 
			
		||||
                                    </svg>
 | 
			
		||||
                                    <span>Screen Recording</span>
 | 
			
		||||
                                `}
 | 
			
		||||
                            </div>
 | 
			
		||||
 | 
			
		||||
                            ${isKeychainRequired ? html`
 | 
			
		||||
                                <div class="permission-item ${this.keychainGranted === 'granted' ? 'granted' : ''}">
 | 
			
		||||
                                    ${this.keychainGranted === 'granted' ? html`
 | 
			
		||||
                                        <svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                            <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
 | 
			
		||||
                                        </svg>
 | 
			
		||||
                                        <span>Data Encryption ✓</span>
 | 
			
		||||
                                    ` : html`
 | 
			
		||||
                                        <svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                            <path fill-rule="evenodd" d="M18 8a6 6 0 01-7.744 5.668l-1.649 1.652c-.63.63-1.706.19-1.706-.742V12.18a.75.75 0 00-1.5 0v2.696c0 .932-1.075 1.372-1.706.742l-1.649-1.652A6 6 0 112 8zm-4 0a.75.75 0 00.75-.75A3.75 3.75 0 018.25 4a.75.75 0 000 1.5 2.25 2.25 0 012.25 2.25.75.75 0 00.75.75z" clip-rule="evenodd" />
 | 
			
		||||
                                        </svg>
 | 
			
		||||
                                        <span>Data Encryption</span>
 | 
			
		||||
                                    `}
 | 
			
		||||
                                </div>
 | 
			
		||||
                            ` : ''}
 | 
			
		||||
                <div class="form-content">
 | 
			
		||||
                    <div class="subtitle">Grant access to microphone and screen recording to continue</div>
 | 
			
		||||
                    
 | 
			
		||||
                    <div class="permission-status">
 | 
			
		||||
                        <div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
 | 
			
		||||
                            ${this.microphoneGranted === 'granted' ? html`
 | 
			
		||||
                                <svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
 | 
			
		||||
                                </svg>
 | 
			
		||||
                                <span>Microphone ✓</span>
 | 
			
		||||
                            ` : html`
 | 
			
		||||
                                <svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                    <path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
 | 
			
		||||
                                </svg>
 | 
			
		||||
                                <span>Microphone</span>
 | 
			
		||||
                            `}
 | 
			
		||||
                        </div>
 | 
			
		||||
                        
 | 
			
		||||
                        <div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
 | 
			
		||||
                            ${this.screenGranted === 'granted' ? html`
 | 
			
		||||
                                <svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                    <path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
 | 
			
		||||
                                </svg>
 | 
			
		||||
                                <span>Screen ✓</span>
 | 
			
		||||
                            ` : html`
 | 
			
		||||
                                <svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
 | 
			
		||||
                                    <path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
 | 
			
		||||
                                </svg>
 | 
			
		||||
                                <span>Screen Recording</span>
 | 
			
		||||
                            `}
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
 | 
			
		||||
                    ${this.microphoneGranted !== 'granted' ? html`
 | 
			
		||||
                        <button 
 | 
			
		||||
                            class="action-button" 
 | 
			
		||||
                            @click=${this.handleMicrophoneClick}
 | 
			
		||||
                            ?disabled=${this.microphoneGranted === 'granted'}
 | 
			
		||||
                        >
 | 
			
		||||
                            ${this.microphoneGranted === 'granted' ? 'Microphone Access Granted' : 'Grant Microphone Access'}
 | 
			
		||||
                            Grant Microphone Access
 | 
			
		||||
                        </button>
 | 
			
		||||
                    ` : ''}
 | 
			
		||||
 | 
			
		||||
                    ${this.screenGranted !== 'granted' ? html`
 | 
			
		||||
                        <button 
 | 
			
		||||
                            class="action-button" 
 | 
			
		||||
                            @click=${this.handleScreenClick}
 | 
			
		||||
                            ?disabled=${this.screenGranted === 'granted'}
 | 
			
		||||
                        >
 | 
			
		||||
                            ${this.screenGranted === 'granted' ? 'Screen Recording Granted' : 'Grant Screen Recording Access'}
 | 
			
		||||
                            Grant Screen Recording Access
 | 
			
		||||
                        </button>
 | 
			
		||||
                    ` : ''}
 | 
			
		||||
 | 
			
		||||
                        ${isKeychainRequired ? html`
 | 
			
		||||
                            <button 
 | 
			
		||||
                                class="action-button" 
 | 
			
		||||
                                @click=${this.handleKeychainClick}
 | 
			
		||||
                                ?disabled=${this.keychainGranted === 'granted'}
 | 
			
		||||
                            >
 | 
			
		||||
                                ${this.keychainGranted === 'granted' ? 'Encryption Enabled' : 'Enable Encryption'}
 | 
			
		||||
                            </button>
 | 
			
		||||
                            <div class="subtitle" style="visibility: ${this.keychainGranted === 'granted' ? 'hidden' : 'visible'}">
 | 
			
		||||
                                Stores the key to encrypt your data. Press "<b>Always Allow</b>" to continue.
 | 
			
		||||
                            </div>
 | 
			
		||||
                        ` : ''}
 | 
			
		||||
                    ` : html`
 | 
			
		||||
                    ${allGranted ? html`
 | 
			
		||||
                        <button 
 | 
			
		||||
                            class="continue-button" 
 | 
			
		||||
                            @click=${this.handleContinue}
 | 
			
		||||
                        >
 | 
			
		||||
                            Continue to Pickle Glass
 | 
			
		||||
                        </button>
 | 
			
		||||
                    `}
 | 
			
		||||
                    ` : ''}
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        `;
 | 
			
		||||
 | 
			
		||||
@ -1,236 +0,0 @@
 | 
			
		||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
 | 
			
		||||
 | 
			
		||||
export class WelcomeHeader extends LitElement {
 | 
			
		||||
    static styles = css`
 | 
			
		||||
        :host {
 | 
			
		||||
            display: block;
 | 
			
		||||
            font-family:
 | 
			
		||||
                'Inter',
 | 
			
		||||
                -apple-system,
 | 
			
		||||
                BlinkMacSystemFont,
 | 
			
		||||
                'Segoe UI',
 | 
			
		||||
                Roboto,
 | 
			
		||||
                sans-serif;
 | 
			
		||||
        }
 | 
			
		||||
        .container {
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            box-sizing: border-box;
 | 
			
		||||
            height: auto;
 | 
			
		||||
            padding: 24px 16px;
 | 
			
		||||
            background: rgba(0, 0, 0, 0.64);
 | 
			
		||||
            box-shadow: 0px 0px 0px 1.5px rgba(255, 255, 255, 0.64) inset;
 | 
			
		||||
            border-radius: 16px;
 | 
			
		||||
            flex-direction: column;
 | 
			
		||||
            justify-content: flex-start;
 | 
			
		||||
            align-items: flex-start;
 | 
			
		||||
            gap: 32px;
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
            -webkit-app-region: drag;
 | 
			
		||||
        }
 | 
			
		||||
        .close-button {
 | 
			
		||||
            -webkit-app-region: no-drag;
 | 
			
		||||
            position: absolute;
 | 
			
		||||
            top: 16px;
 | 
			
		||||
            right: 16px;
 | 
			
		||||
            width: 20px;
 | 
			
		||||
            height: 20px;
 | 
			
		||||
            background: rgba(255, 255, 255, 0.1);
 | 
			
		||||
            border: none;
 | 
			
		||||
            border-radius: 5px;
 | 
			
		||||
            color: rgba(255, 255, 255, 0.7);
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            transition: all 0.15s ease;
 | 
			
		||||
            z-index: 10;
 | 
			
		||||
            font-size: 16px;
 | 
			
		||||
            line-height: 1;
 | 
			
		||||
            padding: 0;
 | 
			
		||||
        }
 | 
			
		||||
        .close-button:hover {
 | 
			
		||||
            background: rgba(255, 255, 255, 0.2);
 | 
			
		||||
            color: rgba(255, 255, 255, 0.9);
 | 
			
		||||
        }
 | 
			
		||||
        .header-section {
 | 
			
		||||
            flex-direction: column;
 | 
			
		||||
            justify-content: flex-start;
 | 
			
		||||
            align-items: flex-start;
 | 
			
		||||
            gap: 4px;
 | 
			
		||||
            display: flex;
 | 
			
		||||
        }
 | 
			
		||||
        .title {
 | 
			
		||||
            color: white;
 | 
			
		||||
            font-size: 18px;
 | 
			
		||||
            font-weight: 700;
 | 
			
		||||
        }
 | 
			
		||||
        .subtitle {
 | 
			
		||||
            color: white;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            font-weight: 500;
 | 
			
		||||
        }
 | 
			
		||||
        .option-card {
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            justify-content: flex-start;
 | 
			
		||||
            align-items: flex-start;
 | 
			
		||||
            gap: 8px;
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
        }
 | 
			
		||||
        .divider {
 | 
			
		||||
            width: 1px;
 | 
			
		||||
            align-self: stretch;
 | 
			
		||||
            position: relative;
 | 
			
		||||
            background: #bebebe;
 | 
			
		||||
            border-radius: 2px;
 | 
			
		||||
        }
 | 
			
		||||
        .option-content {
 | 
			
		||||
            flex: 1 1 0;
 | 
			
		||||
            flex-direction: column;
 | 
			
		||||
            justify-content: flex-start;
 | 
			
		||||
            align-items: flex-start;
 | 
			
		||||
            gap: 8px;
 | 
			
		||||
            display: inline-flex;
 | 
			
		||||
            min-width: 0;
 | 
			
		||||
        }
 | 
			
		||||
        .option-title {
 | 
			
		||||
            color: white;
 | 
			
		||||
            font-size: 14px;
 | 
			
		||||
            font-weight: 700;
 | 
			
		||||
        }
 | 
			
		||||
        .option-description {
 | 
			
		||||
            color: #dcdcdc;
 | 
			
		||||
            font-size: 12px;
 | 
			
		||||
            font-weight: 400;
 | 
			
		||||
            line-height: 18px;
 | 
			
		||||
            letter-spacing: 0.12px;
 | 
			
		||||
            white-space: nowrap;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
            text-overflow: ellipsis;
 | 
			
		||||
        }
 | 
			
		||||
        .action-button {
 | 
			
		||||
            -webkit-app-region: no-drag;
 | 
			
		||||
            padding: 8px 10px;
 | 
			
		||||
            background: rgba(132.6, 132.6, 132.6, 0.8);
 | 
			
		||||
            box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.16);
 | 
			
		||||
            border-radius: 16px;
 | 
			
		||||
            border: 1px solid rgba(255, 255, 255, 0.5);
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            gap: 6px;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
            transition: background-color 0.2s;
 | 
			
		||||
        }
 | 
			
		||||
        .action-button:hover {
 | 
			
		||||
            background: rgba(150, 150, 150, 0.9);
 | 
			
		||||
        }
 | 
			
		||||
        .button-text {
 | 
			
		||||
            color: white;
 | 
			
		||||
            font-size: 12px;
 | 
			
		||||
            font-weight: 600;
 | 
			
		||||
        }
 | 
			
		||||
        .button-icon {
 | 
			
		||||
            width: 12px;
 | 
			
		||||
            height: 12px;
 | 
			
		||||
            position: relative;
 | 
			
		||||
            display: flex;
 | 
			
		||||
            align-items: center;
 | 
			
		||||
            justify-content: center;
 | 
			
		||||
        }
 | 
			
		||||
        .arrow-icon {
 | 
			
		||||
            border: solid white;
 | 
			
		||||
            border-width: 0 1.2px 1.2px 0;
 | 
			
		||||
            display: inline-block;
 | 
			
		||||
            padding: 3px;
 | 
			
		||||
            transform: rotate(-45deg);
 | 
			
		||||
            -webkit-transform: rotate(-45deg);
 | 
			
		||||
        }
 | 
			
		||||
        .footer {
 | 
			
		||||
            align-self: stretch;
 | 
			
		||||
            text-align: center;
 | 
			
		||||
            color: #dcdcdc;
 | 
			
		||||
            font-size: 12px;
 | 
			
		||||
            font-weight: 500;
 | 
			
		||||
            line-height: 19.2px;
 | 
			
		||||
        }
 | 
			
		||||
        .footer-link {
 | 
			
		||||
            text-decoration: underline;
 | 
			
		||||
            cursor: pointer;
 | 
			
		||||
            -webkit-app-region: no-drag;
 | 
			
		||||
        }
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
    static properties = {
 | 
			
		||||
        loginCallback: { type: Function },
 | 
			
		||||
        apiKeyCallback: { type: Function },
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.loginCallback = () => {};
 | 
			
		||||
        this.apiKeyCallback = () => {};
 | 
			
		||||
        this.handleClose = this.handleClose.bind(this);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updated(changedProperties) {
 | 
			
		||||
        super.updated(changedProperties);
 | 
			
		||||
        this.dispatchEvent(new CustomEvent('content-changed', { bubbles: true, composed: true }));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleClose() {
 | 
			
		||||
        if (window.api?.common) {
 | 
			
		||||
            window.api.common.quitApplication();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        return html`
 | 
			
		||||
            <div class="container">
 | 
			
		||||
                <button class="close-button" @click=${this.handleClose}>×</button>
 | 
			
		||||
                <div class="header-section">
 | 
			
		||||
                    <div class="title">Welcome to Glass</div>
 | 
			
		||||
                    <div class="subtitle">Choose how to connect your AI model</div>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="option-card">
 | 
			
		||||
                    <div class="divider"></div>
 | 
			
		||||
                    <div class="option-content">
 | 
			
		||||
                        <div class="option-title">Quick start with default API key</div>
 | 
			
		||||
                        <div class="option-description">
 | 
			
		||||
                            100% free with Pickle's OpenAI key<br/>No personal data collected<br/>Sign up with Google in seconds
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <button class="action-button" @click=${this.loginCallback}>
 | 
			
		||||
                        <div class="button-text">Open Browser to Log in</div>
 | 
			
		||||
                        <div class="button-icon"><div class="arrow-icon"></div></div>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="option-card">
 | 
			
		||||
                    <div class="divider"></div>
 | 
			
		||||
                    <div class="option-content">
 | 
			
		||||
                        <div class="option-title">Use Personal API keys</div>
 | 
			
		||||
                        <div class="option-description">
 | 
			
		||||
                            Costs may apply based on your API usage<br/>No personal data collected<br/>Use your own API keys (OpenAI, Gemini, etc.)
 | 
			
		||||
                        </div>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <button class="action-button" @click=${this.apiKeyCallback}>
 | 
			
		||||
                        <div class="button-text">Enter Your API Key</div>
 | 
			
		||||
                        <div class="button-icon"><div class="arrow-icon"></div></div>
 | 
			
		||||
                    </button>
 | 
			
		||||
                </div>
 | 
			
		||||
                <div class="footer">
 | 
			
		||||
                    Glass does not collect your personal data —
 | 
			
		||||
                    <span class="footer-link" @click=${this.openPrivacyPolicy}>See details</span>
 | 
			
		||||
                </div>
 | 
			
		||||
            </div>
 | 
			
		||||
        `;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    openPrivacyPolicy() {
 | 
			
		||||
        console.log('🔊 openPrivacyPolicy WelcomeHeader');
 | 
			
		||||
        if (window.api?.common) {
 | 
			
		||||
            window.api.common.openExternal('https://pickle.com/privacy-policy');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('welcome-header', WelcomeHeader);
 | 
			
		||||
@ -98,6 +98,133 @@
 | 
			
		||||
                contain: layout style paint;
 | 
			
		||||
                transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .window-sliding-down {
 | 
			
		||||
                animation: slideDownFromHeader 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
 | 
			
		||||
                will-change: transform, opacity;
 | 
			
		||||
                transform-style: preserve-3d;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .window-sliding-up {
 | 
			
		||||
                animation: slideUpToHeader 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
 | 
			
		||||
                will-change: transform, opacity;
 | 
			
		||||
                transform-style: preserve-3d;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .window-hidden {
 | 
			
		||||
                opacity: 0;
 | 
			
		||||
                transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
 | 
			
		||||
                pointer-events: none;
 | 
			
		||||
                will-change: auto;
 | 
			
		||||
                contain: layout style paint;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .listen-window-moving {
 | 
			
		||||
                transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
 | 
			
		||||
                will-change: transform;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .listen-window-center {
 | 
			
		||||
                transform: translate3d(0, 0, 0);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .listen-window-left {
 | 
			
		||||
                transform: translate3d(-110px, 0, 0);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            @keyframes slideDownFromHeader {
 | 
			
		||||
                0% {
 | 
			
		||||
                    opacity: 0;
 | 
			
		||||
                    transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
 | 
			
		||||
                }
 | 
			
		||||
                25% {
 | 
			
		||||
                    opacity: 0.4;
 | 
			
		||||
                    transform: translate3d(0, -10px, 0) scale3d(0.98, 0.98, 1);
 | 
			
		||||
                }
 | 
			
		||||
                50% {
 | 
			
		||||
                    opacity: 0.7;
 | 
			
		||||
                    transform: translate3d(0, -3px, 0) scale3d(1.01, 1.01, 1);
 | 
			
		||||
                }
 | 
			
		||||
                75% {
 | 
			
		||||
                    opacity: 0.9;
 | 
			
		||||
                    transform: translate3d(0, -0.5px, 0) scale3d(1.005, 1.005, 1);
 | 
			
		||||
                }
 | 
			
		||||
                100% {
 | 
			
		||||
                    opacity: 1;
 | 
			
		||||
                    transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .settings-window-show {
 | 
			
		||||
                animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
 | 
			
		||||
                transform-origin: 85% 0%;
 | 
			
		||||
                will-change: transform, opacity;
 | 
			
		||||
                transform-style: preserve-3d;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            .settings-window-hide {
 | 
			
		||||
                animation: settingsCollapseToButton 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
 | 
			
		||||
                transform-origin: 85% 0%;
 | 
			
		||||
                will-change: transform, opacity;
 | 
			
		||||
                transform-style: preserve-3d;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            @keyframes settingsPopFromButton {
 | 
			
		||||
                0% {
 | 
			
		||||
                    opacity: 0;
 | 
			
		||||
                    transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
 | 
			
		||||
                }
 | 
			
		||||
                40% {
 | 
			
		||||
                    opacity: 0.8;
 | 
			
		||||
                    transform: translate3d(0, -2px, 0) scale3d(1.05, 1.05, 1);
 | 
			
		||||
                }
 | 
			
		||||
                70% {
 | 
			
		||||
                    opacity: 0.95;
 | 
			
		||||
                    transform: translate3d(0, 0, 0) scale3d(1.02, 1.02, 1);
 | 
			
		||||
                }
 | 
			
		||||
                100% {
 | 
			
		||||
                    opacity: 1;
 | 
			
		||||
                    transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            @keyframes settingsCollapseToButton {
 | 
			
		||||
                0% {
 | 
			
		||||
                    opacity: 1;
 | 
			
		||||
                    transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
 | 
			
		||||
                }
 | 
			
		||||
                30% {
 | 
			
		||||
                    opacity: 0.8;
 | 
			
		||||
                    transform: translate3d(0, -1px, 0) scale3d(0.9, 0.9, 1);
 | 
			
		||||
                }
 | 
			
		||||
                70% {
 | 
			
		||||
                    opacity: 0.3;
 | 
			
		||||
                    transform: translate3d(0, -5px, 0) scale3d(0.7, 0.7, 1);
 | 
			
		||||
                }
 | 
			
		||||
                100% {
 | 
			
		||||
                    opacity: 0;
 | 
			
		||||
                    transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            @keyframes slideUpToHeader {
 | 
			
		||||
                0% {
 | 
			
		||||
                    opacity: 1;
 | 
			
		||||
                    transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
 | 
			
		||||
                }
 | 
			
		||||
                30% {
 | 
			
		||||
                    opacity: 0.6;
 | 
			
		||||
                    transform: translate3d(0, -6px, 0) scale3d(0.98, 0.98, 1);
 | 
			
		||||
                }
 | 
			
		||||
                65% {
 | 
			
		||||
                    opacity: 0.2;
 | 
			
		||||
                    transform: translate3d(0, -14px, 0) scale3d(0.95, 0.95, 1);
 | 
			
		||||
                }
 | 
			
		||||
                100% {
 | 
			
		||||
                    opacity: 0;
 | 
			
		||||
                    transform: translate3d(0, -18px, 0) scale3d(0.93, 0.93, 1);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        </style>
 | 
			
		||||
    </head>
 | 
			
		||||
    <body>
 | 
			
		||||
@ -110,7 +237,65 @@
 | 
			
		||||
        <script>
 | 
			
		||||
            window.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
                const app = document.getElementById('pickle-glass');
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
                if (window.api) {
 | 
			
		||||
                    // --- REFACTORED: Event-driven animation handling ---
 | 
			
		||||
                    app.addEventListener('animationend', (event) => {
 | 
			
		||||
                        // 숨김 애니메이션이 끝나면 main 프로세스에 알려 창을 실제로 숨깁니다.
 | 
			
		||||
                        if (event.animationName === 'slideUpToHeader' || event.animationName === 'settingsCollapseToButton') {
 | 
			
		||||
                            console.log(`Animation finished: ${event.animationName}. Notifying main process.`);
 | 
			
		||||
                            window.api.content.sendAnimationFinished();
 | 
			
		||||
        
 | 
			
		||||
                            // 완료 후 애니메이션 클래스 정리
 | 
			
		||||
                            app.classList.remove('window-sliding-up', 'settings-window-hide');
 | 
			
		||||
                            app.classList.add('window-hidden');
 | 
			
		||||
                        } else if (event.animationName === 'slideDownFromHeader' || event.animationName === 'settingsPopFromButton') {
 | 
			
		||||
                             // 보이기 애니메이션 완료 후 클래스 정리
 | 
			
		||||
                            app.classList.remove('window-sliding-down', 'settings-window-show');
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    window.api.content.onWindowShowAnimation(() => {
 | 
			
		||||
                        console.log('Starting window show animation');
 | 
			
		||||
                        app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
 | 
			
		||||
                        app.classList.add('window-sliding-down');
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    window.api.content.onWindowHideAnimation(() => {
 | 
			
		||||
                        console.log('Starting window hide animation');
 | 
			
		||||
                        app.classList.remove('window-sliding-down', 'settings-window-show');
 | 
			
		||||
                        app.classList.add('window-sliding-up');
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    window.api.content.onSettingsWindowHideAnimation(() => {
 | 
			
		||||
                        console.log('Starting settings window hide animation');
 | 
			
		||||
                        app.classList.remove('window-sliding-down', 'settings-window-show');
 | 
			
		||||
                        app.classList.add('settings-window-hide');
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    // --- UNCHANGED: Existing logic for listen window movement ---
 | 
			
		||||
                    window.api.content.onListenWindowMoveToCenter(() => {
 | 
			
		||||
                        console.log('Moving listen window to center');
 | 
			
		||||
                        app.classList.add('listen-window-moving');
 | 
			
		||||
                        app.classList.remove('listen-window-left');
 | 
			
		||||
                        app.classList.add('listen-window-center');
 | 
			
		||||
        
 | 
			
		||||
                        setTimeout(() => {
 | 
			
		||||
                            app.classList.remove('listen-window-moving');
 | 
			
		||||
                        }, 350);
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    window.api.content.onListenWindowMoveToLeft(() => {
 | 
			
		||||
                        console.log('Moving listen window to left');
 | 
			
		||||
                        app.classList.add('listen-window-moving');
 | 
			
		||||
                        app.classList.remove('listen-window-center');
 | 
			
		||||
                        app.classList.add('listen-window-left');
 | 
			
		||||
        
 | 
			
		||||
                        setTimeout(() => {
 | 
			
		||||
                            app.classList.remove('listen-window-moving');
 | 
			
		||||
                        }, 350);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        </script>
 | 
			
		||||
        <script>
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,4 @@
 | 
			
		||||
import { html, css, LitElement } from '../../ui/assets/lit-core-2.7.4.min.js';
 | 
			
		||||
import { parser, parser_write, parser_end, default_renderer } from '../../ui/assets/smd.js';
 | 
			
		||||
 | 
			
		||||
export class AskView extends LitElement {
 | 
			
		||||
    static properties = {
 | 
			
		||||
@ -503,7 +502,6 @@ export class AskView extends LitElement {
 | 
			
		||||
            padding: 0;
 | 
			
		||||
            height: 0;
 | 
			
		||||
            overflow: hidden;
 | 
			
		||||
            border-top: none;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        .text-input-container.no-response {
 | 
			
		||||
@ -727,10 +725,6 @@ export class AskView extends LitElement {
 | 
			
		||||
        this.DOMPurify = null;
 | 
			
		||||
        this.isLibrariesLoaded = false;
 | 
			
		||||
 | 
			
		||||
        // SMD.js streaming markdown parser
 | 
			
		||||
        this.smdParser = null;
 | 
			
		||||
        this.smdContainer = null;
 | 
			
		||||
        this.lastProcessedLength = 0;
 | 
			
		||||
 | 
			
		||||
        this.handleSendText = this.handleSendText.bind(this);
 | 
			
		||||
        this.handleTextKeydown = this.handleTextKeydown.bind(this);
 | 
			
		||||
@ -769,19 +763,17 @@ export class AskView extends LitElement {
 | 
			
		||||
        if (container) this.resizeObserver.observe(container);
 | 
			
		||||
 | 
			
		||||
        this.handleQuestionFromAssistant = (event, question) => {
 | 
			
		||||
            console.log('AskView: Received question from ListenView:', question);
 | 
			
		||||
            console.log('📨 AskView: Received question from ListenView:', question);
 | 
			
		||||
            this.handleSendText(null, question);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.askView.onShowTextInput(() => {
 | 
			
		||||
                console.log('Show text input signal received');
 | 
			
		||||
                console.log('📤 Show text input signal received');
 | 
			
		||||
                if (!this.showTextInput) {
 | 
			
		||||
                    this.showTextInput = true;
 | 
			
		||||
                    this.updateComplete.then(() => this.focusTextInput());
 | 
			
		||||
                  } else {
 | 
			
		||||
                    this.focusTextInput();
 | 
			
		||||
                  }
 | 
			
		||||
                    this.requestUpdate();
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            window.api.askView.onScrollResponseUp(() => this.handleScroll('up'));
 | 
			
		||||
@ -789,21 +781,11 @@ export class AskView extends LitElement {
 | 
			
		||||
            window.api.askView.onAskStateUpdate((event, newState) => {
 | 
			
		||||
                this.currentResponse = newState.currentResponse;
 | 
			
		||||
                this.currentQuestion = newState.currentQuestion;
 | 
			
		||||
                this.isLoading       = newState.isLoading;
 | 
			
		||||
                this.isStreaming     = newState.isStreaming;
 | 
			
		||||
              
 | 
			
		||||
                const wasHidden = !this.showTextInput;
 | 
			
		||||
                this.isLoading = newState.isLoading;
 | 
			
		||||
                this.isStreaming = newState.isStreaming;
 | 
			
		||||
                this.showTextInput = newState.showTextInput;
 | 
			
		||||
              
 | 
			
		||||
                if (newState.showTextInput) {
 | 
			
		||||
                  if (wasHidden) {
 | 
			
		||||
                    this.updateComplete.then(() => this.focusTextInput());
 | 
			
		||||
                  } else {
 | 
			
		||||
                    this.focusTextInput();
 | 
			
		||||
                  }
 | 
			
		||||
                }
 | 
			
		||||
              });
 | 
			
		||||
            console.log('AskView: IPC 이벤트 리스너 등록 완료');
 | 
			
		||||
            });
 | 
			
		||||
            console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -896,7 +878,7 @@ export class AskView extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleCloseAskWindow() {
 | 
			
		||||
        // this.clearResponseContent();
 | 
			
		||||
        this.clearResponseContent();
 | 
			
		||||
        window.api.askView.closeAskWindow();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -920,9 +902,6 @@ export class AskView extends LitElement {
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.headerText = 'AI Response';
 | 
			
		||||
        this.showTextInput = true;
 | 
			
		||||
        this.lastProcessedLength = 0;
 | 
			
		||||
        this.smdParser = null;
 | 
			
		||||
        this.smdContainer = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleInputFocus() {
 | 
			
		||||
@ -994,89 +973,22 @@ export class AskView extends LitElement {
 | 
			
		||||
        const responseContainer = this.shadowRoot.getElementById('responseContainer');
 | 
			
		||||
        if (!responseContainer) return;
 | 
			
		||||
    
 | 
			
		||||
        // Check loading state
 | 
			
		||||
        // ✨ 로딩 상태를 먼저 확인
 | 
			
		||||
        if (this.isLoading) {
 | 
			
		||||
            responseContainer.innerHTML = `
 | 
			
		||||
              <div class="loading-dots">
 | 
			
		||||
                <div class="loading-dot"></div>
 | 
			
		||||
                <div class="loading-dot"></div>
 | 
			
		||||
                <div class="loading-dot"></div>
 | 
			
		||||
              </div>`;
 | 
			
		||||
            this.resetStreamingParser();
 | 
			
		||||
            responseContainer.innerHTML = `<div class="loading-dots">...</div>`;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // If there is no response, show empty state
 | 
			
		||||
        // ✨ 응답이 없을 때의 처리
 | 
			
		||||
        if (!this.currentResponse) {
 | 
			
		||||
            responseContainer.innerHTML = `<div class="empty-state">...</div>`;
 | 
			
		||||
            this.resetStreamingParser();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Set streaming markdown parser
 | 
			
		||||
        this.renderStreamingMarkdown(responseContainer);
 | 
			
		||||
        let textToRender = this.fixIncompleteMarkdown(this.currentResponse);
 | 
			
		||||
        textToRender = this.fixIncompleteCodeBlocks(textToRender);
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
        // After updating content, recalculate window height
 | 
			
		||||
        this.adjustWindowHeightThrottled();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    resetStreamingParser() {
 | 
			
		||||
        this.smdParser = null;
 | 
			
		||||
        this.smdContainer = null;
 | 
			
		||||
        this.lastProcessedLength = 0;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderStreamingMarkdown(responseContainer) {
 | 
			
		||||
        try {
 | 
			
		||||
            // 파서가 없거나 컨테이너가 변경되었으면 새로 생성
 | 
			
		||||
            if (!this.smdParser || this.smdContainer !== responseContainer) {
 | 
			
		||||
                this.smdContainer = responseContainer;
 | 
			
		||||
                this.smdContainer.innerHTML = '';
 | 
			
		||||
                
 | 
			
		||||
                // smd.js의 default_renderer 사용
 | 
			
		||||
                const renderer = default_renderer(this.smdContainer);
 | 
			
		||||
                this.smdParser = parser(renderer);
 | 
			
		||||
                this.lastProcessedLength = 0;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 새로운 텍스트만 처리 (스트리밍 최적화)
 | 
			
		||||
            const currentText = this.currentResponse;
 | 
			
		||||
            const newText = currentText.slice(this.lastProcessedLength);
 | 
			
		||||
            
 | 
			
		||||
            if (newText.length > 0) {
 | 
			
		||||
                // 새로운 텍스트 청크를 파서에 전달
 | 
			
		||||
                parser_write(this.smdParser, newText);
 | 
			
		||||
                this.lastProcessedLength = currentText.length;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 스트리밍이 완료되면 파서 종료
 | 
			
		||||
            if (!this.isStreaming && !this.isLoading) {
 | 
			
		||||
                parser_end(this.smdParser);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 코드 하이라이팅 적용
 | 
			
		||||
            if (this.hljs) {
 | 
			
		||||
                responseContainer.querySelectorAll('pre code').forEach(block => {
 | 
			
		||||
                    if (!block.hasAttribute('data-highlighted')) {
 | 
			
		||||
                        this.hljs.highlightElement(block);
 | 
			
		||||
                        block.setAttribute('data-highlighted', 'true');
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // 스크롤을 맨 아래로
 | 
			
		||||
            responseContainer.scrollTop = responseContainer.scrollHeight;
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error rendering streaming markdown:', error);
 | 
			
		||||
            // 에러 발생 시 기본 텍스트 렌더링으로 폴백
 | 
			
		||||
            this.renderFallbackContent(responseContainer);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    renderFallbackContent(responseContainer) {
 | 
			
		||||
        const textToRender = this.currentResponse || '';
 | 
			
		||||
        
 | 
			
		||||
        if (this.isLibrariesLoaded && this.marked && this.DOMPurify) {
 | 
			
		||||
            try {
 | 
			
		||||
                // 마크다운 파싱
 | 
			
		||||
@ -1085,13 +997,42 @@ export class AskView extends LitElement {
 | 
			
		||||
                // DOMPurify로 정제
 | 
			
		||||
                const cleanHtml = this.DOMPurify.sanitize(parsedHtml, {
 | 
			
		||||
                    ALLOWED_TAGS: [
 | 
			
		||||
                        'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'b', 'em', 'i',
 | 
			
		||||
                        'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'img', 'table', 'thead',
 | 
			
		||||
                        'tbody', 'tr', 'th', 'td', 'hr', 'sup', 'sub', 'del', 'ins',
 | 
			
		||||
                        'h1',
 | 
			
		||||
                        'h2',
 | 
			
		||||
                        'h3',
 | 
			
		||||
                        'h4',
 | 
			
		||||
                        'h5',
 | 
			
		||||
                        'h6',
 | 
			
		||||
                        'p',
 | 
			
		||||
                        'br',
 | 
			
		||||
                        'strong',
 | 
			
		||||
                        'b',
 | 
			
		||||
                        'em',
 | 
			
		||||
                        'i',
 | 
			
		||||
                        'ul',
 | 
			
		||||
                        'ol',
 | 
			
		||||
                        'li',
 | 
			
		||||
                        'blockquote',
 | 
			
		||||
                        'code',
 | 
			
		||||
                        'pre',
 | 
			
		||||
                        'a',
 | 
			
		||||
                        'img',
 | 
			
		||||
                        'table',
 | 
			
		||||
                        'thead',
 | 
			
		||||
                        'tbody',
 | 
			
		||||
                        'tr',
 | 
			
		||||
                        'th',
 | 
			
		||||
                        'td',
 | 
			
		||||
                        'hr',
 | 
			
		||||
                        'sup',
 | 
			
		||||
                        'sub',
 | 
			
		||||
                        'del',
 | 
			
		||||
                        'ins',
 | 
			
		||||
                    ],
 | 
			
		||||
                    ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'id', 'target', 'rel'],
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                // HTML 적용
 | 
			
		||||
                responseContainer.innerHTML = cleanHtml;
 | 
			
		||||
 | 
			
		||||
                // 코드 하이라이팅 적용
 | 
			
		||||
@ -1100,8 +1041,12 @@ export class AskView extends LitElement {
 | 
			
		||||
                        this.hljs.highlightElement(block);
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                // 스크롤을 맨 아래로
 | 
			
		||||
                responseContainer.scrollTop = responseContainer.scrollHeight;
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error in fallback rendering:', error);
 | 
			
		||||
                console.error('Error rendering markdown:', error);
 | 
			
		||||
                // 에러 발생 시 일반 텍스트로 표시
 | 
			
		||||
                responseContainer.textContent = textToRender;
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
@ -1118,6 +1063,9 @@ export class AskView extends LitElement {
 | 
			
		||||
 | 
			
		||||
            responseContainer.innerHTML = `<p>${basicHtml}</p>`;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // 🚀 After updating content, recalculate window height
 | 
			
		||||
        this.adjustWindowHeightThrottled();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1272,7 +1220,7 @@ export class AskView extends LitElement {
 | 
			
		||||
    async handleSendText(e, overridingText = '') {
 | 
			
		||||
        const textInput = this.shadowRoot?.getElementById('textInput');
 | 
			
		||||
        const text = (overridingText || textInput?.value || '').trim();
 | 
			
		||||
        // if (!text) return;
 | 
			
		||||
        if (!text) return;
 | 
			
		||||
 | 
			
		||||
        textInput.value = '';
 | 
			
		||||
 | 
			
		||||
@ -1422,7 +1370,7 @@ export class AskView extends LitElement {
 | 
			
		||||
 | 
			
		||||
            const targetHeight = Math.min(700, idealHeight);
 | 
			
		||||
 | 
			
		||||
            window.api.askView.adjustWindowHeight("ask", targetHeight);
 | 
			
		||||
            window.api.askView.adjustWindowHeight(targetHeight);
 | 
			
		||||
 | 
			
		||||
        }).catch(err => console.error('AskView adjustWindowHeight error:', err));
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										1665
									
								
								src/ui/assets/smd.js
									
									
									
									
									
								
							
							
						
						
									
										1665
									
								
								src/ui/assets/smd.js
									
									
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -536,7 +536,7 @@ export class ListenView extends LitElement {
 | 
			
		||||
                    `[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px`
 | 
			
		||||
                );
 | 
			
		||||
 | 
			
		||||
                window.api.listenView.adjustWindowHeight('listen', targetHeight);
 | 
			
		||||
                window.api.listenView.adjustWindowHeight(targetHeight);
 | 
			
		||||
            })
 | 
			
		||||
            .catch(error => {
 | 
			
		||||
                console.error('Error in adjustWindowHeight:', error);
 | 
			
		||||
 | 
			
		||||
@ -422,12 +422,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        if (isMacOS) {
 | 
			
		||||
 | 
			
		||||
            const sessionActive = await window.api.listenCapture.isSessionActive();
 | 
			
		||||
            if (!sessionActive) {
 | 
			
		||||
                throw new Error('STT sessions not initialized - please wait for initialization to complete');
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // On macOS, use SystemAudioDump for audio and getDisplayMedia for screen
 | 
			
		||||
            console.log('Starting macOS capture with SystemAudioDump...');
 | 
			
		||||
 | 
			
		||||
@ -472,12 +466,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
 | 
			
		||||
 | 
			
		||||
            console.log('macOS screen capture started - audio handled by SystemAudioDump');
 | 
			
		||||
        } else if (isLinux) {
 | 
			
		||||
 | 
			
		||||
            const sessionActive = await window.api.listenCapture.isSessionActive();
 | 
			
		||||
            if (!sessionActive) {
 | 
			
		||||
                throw new Error('STT sessions not initialized - please wait for initialization to complete');
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Linux - use display media for screen capture and getUserMedia for microphone
 | 
			
		||||
            mediaStream = await navigator.mediaDevices.getDisplayMedia({
 | 
			
		||||
                video: {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
 | 
			
		||||
// import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; // 제거됨
 | 
			
		||||
import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js';
 | 
			
		||||
 | 
			
		||||
export class SettingsView extends LitElement {
 | 
			
		||||
    static styles = css`
 | 
			
		||||
@ -531,6 +531,7 @@ export class SettingsView extends LitElement {
 | 
			
		||||
        this.ollamaStatus = { installed: false, running: false };
 | 
			
		||||
        this.ollamaModels = [];
 | 
			
		||||
        this.installingModels = {}; // { modelName: progress }
 | 
			
		||||
        this.progressTracker = getOllamaProgressTracker();
 | 
			
		||||
        // Whisper related
 | 
			
		||||
        this.whisperModels = [];
 | 
			
		||||
        this.whisperProgressTracker = null; // Will be initialized when needed
 | 
			
		||||
@ -575,50 +576,19 @@ 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 {
 | 
			
		||||
            // Load essential data first
 | 
			
		||||
            const [userState, modelSettings, presets, contentProtection, shortcuts] = await Promise.all([
 | 
			
		||||
            const [userState, modelSettings, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = 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.getCurrentShortcuts(),
 | 
			
		||||
                window.api.settingsView.getOllamaStatus(),
 | 
			
		||||
                window.api.settingsView.getWhisperInstalledModels()
 | 
			
		||||
            ]);
 | 
			
		||||
            
 | 
			
		||||
            if (userState && userState.isLoggedIn) this.firebaseUser = userState;
 | 
			
		||||
@ -640,9 +610,23 @@ export class SettingsView extends LitElement {
 | 
			
		||||
                const firstUserPreset = this.presets.find(p => p.is_default === 0);
 | 
			
		||||
                if (firstUserPreset) this.selectedPreset = firstUserPreset;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Load LocalAI status asynchronously to improve initial load time
 | 
			
		||||
            this.loadLocalAIStatus();
 | 
			
		||||
            // 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;
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error loading initial settings data:', error);
 | 
			
		||||
        } finally {
 | 
			
		||||
@ -791,42 +775,31 @@ export class SettingsView extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    async installOllamaModel(modelName) {
 | 
			
		||||
        // Mark as installing
 | 
			
		||||
        this.installingModels = { ...this.installingModels, [modelName]: 0 };
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            // Ollama 모델 다운로드 시작
 | 
			
		||||
            this.installingModels = { ...this.installingModels, [modelName]: 0 };
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
 | 
			
		||||
            // 진행률 이벤트 리스너 설정 - 통합 LocalAI 이벤트 사용
 | 
			
		||||
            const progressHandler = (event, data) => {
 | 
			
		||||
                if (data.service === 'ollama' && data.model === modelName) {
 | 
			
		||||
                    this.installingModels = { ...this.installingModels, [modelName]: data.progress || 0 };
 | 
			
		||||
                    this.requestUpdate();
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            // 통합 LocalAI 이벤트 리스너 등록
 | 
			
		||||
            window.api.settingsView.onLocalAIInstallProgress(progressHandler);
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                const result = await window.api.settingsView.pullOllamaModel(modelName);
 | 
			
		||||
                
 | 
			
		||||
                if (result.success) {
 | 
			
		||||
                    console.log(`[SettingsView] Model ${modelName} installed successfully`);
 | 
			
		||||
                    delete this.installingModels[modelName];
 | 
			
		||||
                    this.requestUpdate();
 | 
			
		||||
                    
 | 
			
		||||
                    // 상태 새로고침
 | 
			
		||||
                    await this.refreshOllamaStatus();
 | 
			
		||||
                    await this.refreshModelData();
 | 
			
		||||
                } else {
 | 
			
		||||
                    throw new Error(result.error || 'Installation failed');
 | 
			
		||||
                }
 | 
			
		||||
            } finally {
 | 
			
		||||
                // 통합 LocalAI 이벤트 리스너 제거
 | 
			
		||||
                window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
 | 
			
		||||
            // Use the clean progress tracker - no manual event management needed
 | 
			
		||||
            const success = await this.progressTracker.installModel(modelName, (progress) => {
 | 
			
		||||
                this.installingModels = { ...this.installingModels, [modelName]: progress };
 | 
			
		||||
                this.requestUpdate();
 | 
			
		||||
            });
 | 
			
		||||
            
 | 
			
		||||
            if (success) {
 | 
			
		||||
                // Refresh status after installation
 | 
			
		||||
                await this.refreshOllamaStatus();
 | 
			
		||||
                await this.refreshModelData();
 | 
			
		||||
                // Auto-select the model after installation
 | 
			
		||||
                await this.selectModel('llm', modelName);
 | 
			
		||||
            } else {
 | 
			
		||||
                alert(`Installation of ${modelName} was cancelled`);
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error(`[SettingsView] Error installing model ${modelName}:`, error);
 | 
			
		||||
            alert(`Error installing ${modelName}: ${error.message}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            // Automatic cleanup - no manual event listener management
 | 
			
		||||
            delete this.installingModels[modelName];
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
        }
 | 
			
		||||
@ -838,52 +811,34 @@ export class SettingsView extends LitElement {
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            // Set up progress listener - 통합 LocalAI 이벤트 사용
 | 
			
		||||
            const progressHandler = (event, data) => {
 | 
			
		||||
                if (data.service === 'whisper' && data.model === modelId) {
 | 
			
		||||
                    this.installingModels = { ...this.installingModels, [modelId]: data.progress || 0 };
 | 
			
		||||
            // Set up progress listener
 | 
			
		||||
            const progressHandler = (event, { modelId: id, progress }) => {
 | 
			
		||||
                if (id === modelId) {
 | 
			
		||||
                    this.installingModels = { ...this.installingModels, [modelId]: progress };
 | 
			
		||||
                    this.requestUpdate();
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            window.api.settingsView.onLocalAIInstallProgress(progressHandler);
 | 
			
		||||
            window.api.settingsView.onWhisperDownloadProgress(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.removeOnLocalAIInstallProgress(progressHandler);
 | 
			
		||||
            window.api.settingsView.removeOnWhisperDownloadProgress(progressHandler);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error);
 | 
			
		||||
            // Remove from installing models on error
 | 
			
		||||
            alert(`Error downloading ${modelId}: ${error.message}`);
 | 
			
		||||
        } finally {
 | 
			
		||||
            delete this.installingModels[modelId];
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
            alert(`Error downloading ${modelId}: ${error.message}`);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
@ -897,6 +852,12 @@ 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()
 | 
			
		||||
@ -908,7 +869,7 @@ export class SettingsView extends LitElement {
 | 
			
		||||
    //////// after_modelStateService ////////
 | 
			
		||||
 | 
			
		||||
    openShortcutEditor() {
 | 
			
		||||
        window.api.settingsView.openShortcutSettingsWindow();
 | 
			
		||||
        window.api.settingsView.openShortcutEditor();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
@ -918,8 +879,6 @@ export class SettingsView extends LitElement {
 | 
			
		||||
        this.setupIpcListeners();
 | 
			
		||||
        this.setupWindowResize();
 | 
			
		||||
        this.loadAutoUpdateSetting();
 | 
			
		||||
        // Force one height calculation immediately (innerHeight may be 0 at first)
 | 
			
		||||
        setTimeout(() => this.updateScrollHeight(), 0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    disconnectedCallback() {
 | 
			
		||||
@ -932,7 +891,7 @@ export class SettingsView extends LitElement {
 | 
			
		||||
        const installingModels = Object.keys(this.installingModels);
 | 
			
		||||
        if (installingModels.length > 0) {
 | 
			
		||||
            installingModels.forEach(modelName => {
 | 
			
		||||
                window.api.settingsView.cancelOllamaInstallation(modelName);
 | 
			
		||||
                this.progressTracker.cancelInstallation(modelName);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -958,8 +917,7 @@ export class SettingsView extends LitElement {
 | 
			
		||||
                this.firebaseUser = null;
 | 
			
		||||
            }
 | 
			
		||||
            this.loadAutoUpdateSetting();
 | 
			
		||||
            // Reload model settings when user state changes (Firebase login/logout)
 | 
			
		||||
            this.loadInitialData();
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        this._settingsUpdatedListener = (event, settings) => {
 | 
			
		||||
@ -1032,13 +990,11 @@ export class SettingsView extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updateScrollHeight() {
 | 
			
		||||
        // Electron 일부 시점에서 window.innerHeight 가 0 으로 보고되는 버그 보호
 | 
			
		||||
        const rawHeight = window.innerHeight || (window.screen ? window.screen.height : 0);
 | 
			
		||||
        const MIN_HEIGHT = 300; // 최소 보장 높이
 | 
			
		||||
        const maxHeight = Math.max(MIN_HEIGHT, rawHeight);
 | 
			
		||||
 | 
			
		||||
        const windowHeight = window.innerHeight;
 | 
			
		||||
        const maxHeight = windowHeight;
 | 
			
		||||
        
 | 
			
		||||
        this.style.maxHeight = `${maxHeight}px`;
 | 
			
		||||
 | 
			
		||||
        
 | 
			
		||||
        const container = this.shadowRoot?.querySelector('.settings-container');
 | 
			
		||||
        if (container) {
 | 
			
		||||
            container.style.maxHeight = `${maxHeight}px`;
 | 
			
		||||
@ -1047,15 +1003,19 @@ export class SettingsView extends LitElement {
 | 
			
		||||
 | 
			
		||||
    handleMouseEnter = () => {
 | 
			
		||||
        window.api.settingsView.cancelHideSettingsWindow();
 | 
			
		||||
        // Recalculate height in case it was set to 0 before
 | 
			
		||||
        this.updateScrollHeight();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleMouseLeave = () => {
 | 
			
		||||
        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 },
 | 
			
		||||
@ -1228,7 +1188,12 @@ export class SettingsView extends LitElement {
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        if (id === 'whisper') {
 | 
			
		||||
                            // Simplified UI for Whisper without model selection
 | 
			
		||||
                            // Special UI for Whisper with model selection
 | 
			
		||||
                            const whisperModels = config.sttModels || [];
 | 
			
		||||
                            const selectedWhisperModel = this.selectedStt && this.getProviderForModel('stt', this.selectedStt) === 'whisper' 
 | 
			
		||||
                                ? this.selectedStt 
 | 
			
		||||
                                : null;
 | 
			
		||||
                            
 | 
			
		||||
                            return html`
 | 
			
		||||
                                <div class="provider-key-group">
 | 
			
		||||
                                    <label>${config.name} (Local STT)</label>
 | 
			
		||||
@ -1236,6 +1201,51 @@ 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>
 | 
			
		||||
@ -1317,9 +1327,6 @@ 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;
 | 
			
		||||
                                
 | 
			
		||||
@ -1327,16 +1334,10 @@ 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 ? 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>
 | 
			
		||||
                                            `}
 | 
			
		||||
                                        ${isWhisper && isInstalling ? html`
 | 
			
		||||
                                            <div class="install-progress">
 | 
			
		||||
                                                <div class="install-progress-bar" style="width: ${installProgress}%"></div>
 | 
			
		||||
                                            </div>
 | 
			
		||||
                                        ` : ''}
 | 
			
		||||
                                    </div>
 | 
			
		||||
                                `;
 | 
			
		||||
 | 
			
		||||
@ -171,7 +171,6 @@ export class ShortcutSettingsView extends LitElement {
 | 
			
		||||
 | 
			
		||||
    async handleSave() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        this.feedback = {};
 | 
			
		||||
        const result = await window.api.shortcutSettingsView.saveShortcuts(this.shortcuts);
 | 
			
		||||
        if (!result.success) {
 | 
			
		||||
            alert('Failed to save shortcuts: ' + result.error);
 | 
			
		||||
@ -180,8 +179,7 @@ export class ShortcutSettingsView extends LitElement {
 | 
			
		||||
 | 
			
		||||
    handleClose() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        this.feedback = {};
 | 
			
		||||
        window.api.shortcutSettingsView.closeShortcutSettingsWindow();
 | 
			
		||||
        window.api.shortcutSettingsView.closeShortcutEditor();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleResetToDefault() {
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,11 @@
 | 
			
		||||
const { screen } = require('electron');
 | 
			
		||||
 | 
			
		||||
class SmoothMovementManager {
 | 
			
		||||
    constructor(windowPool) {
 | 
			
		||||
    constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
 | 
			
		||||
        this.windowPool = windowPool;
 | 
			
		||||
        this.getDisplayById = getDisplayById;
 | 
			
		||||
        this.getCurrentDisplay = getCurrentDisplay;
 | 
			
		||||
        this.updateLayout = updateLayout;
 | 
			
		||||
        this.stepSize = 80;
 | 
			
		||||
        this.animationDuration = 300;
 | 
			
		||||
        this.headerPosition = { x: 0, y: 0 };
 | 
			
		||||
@ -11,8 +14,6 @@ class SmoothMovementManager {
 | 
			
		||||
        this.lastVisiblePosition = null;
 | 
			
		||||
        this.currentDisplayId = null;
 | 
			
		||||
        this.animationFrameId = null;
 | 
			
		||||
 | 
			
		||||
        this.animationTimers = new Map();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -21,162 +22,248 @@ class SmoothMovementManager {
 | 
			
		||||
     */
 | 
			
		||||
    _isWindowValid(win) {
 | 
			
		||||
        if (!win || win.isDestroyed()) {
 | 
			
		||||
            // 해당 창의 타이머가 있으면 정리
 | 
			
		||||
            if (this.animationTimers.has(win)) {
 | 
			
		||||
                clearTimeout(this.animationTimers.get(win));
 | 
			
		||||
                this.animationTimers.delete(win);
 | 
			
		||||
            if (this.isAnimating) {
 | 
			
		||||
                console.warn('[MovementManager] Window destroyed mid-animation. Halting.');
 | 
			
		||||
                this.isAnimating = false;
 | 
			
		||||
                if (this.animationFrameId) {
 | 
			
		||||
                    clearTimeout(this.animationFrameId);
 | 
			
		||||
                    this.animationFrameId = null;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {BrowserWindow} win
 | 
			
		||||
     * @param {number} targetX
 | 
			
		||||
     * @param {number} targetY
 | 
			
		||||
     * @param {object} [options]
 | 
			
		||||
     * @param {object} [options.sizeOverride]
 | 
			
		||||
     * @param {function} [options.onComplete]
 | 
			
		||||
     * @param {number} [options.duration]
 | 
			
		||||
     */
 | 
			
		||||
    animateWindow(win, targetX, targetY, options = {}) {
 | 
			
		||||
        if (!this._isWindowValid(win)) {
 | 
			
		||||
            if (options.onComplete) options.onComplete();
 | 
			
		||||
    moveToDisplay(displayId) {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
 | 
			
		||||
 | 
			
		||||
        const targetDisplay = this.getDisplayById(displayId);
 | 
			
		||||
        if (!targetDisplay) return;
 | 
			
		||||
 | 
			
		||||
        const currentBounds = header.getBounds();
 | 
			
		||||
        const currentDisplay = this.getCurrentDisplay(header);
 | 
			
		||||
 | 
			
		||||
        if (currentDisplay.id === targetDisplay.id) return;
 | 
			
		||||
 | 
			
		||||
        const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
 | 
			
		||||
        const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
 | 
			
		||||
        const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
 | 
			
		||||
        const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
 | 
			
		||||
 | 
			
		||||
        const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
 | 
			
		||||
        const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
 | 
			
		||||
 | 
			
		||||
        this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
 | 
			
		||||
        this.animateToPosition(header, finalX, finalY);
 | 
			
		||||
        this.currentDisplayId = targetDisplay.id;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    hideToEdge(edge, callback, { instant = false } = {}) {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (!header || header.isDestroyed()) {
 | 
			
		||||
            if (typeof callback === 'function') callback();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        const { x, y } = header.getBounds();
 | 
			
		||||
        this.lastVisiblePosition = { x, y };
 | 
			
		||||
        this.hiddenPosition     = { edge };
 | 
			
		||||
      
 | 
			
		||||
        if (instant) {
 | 
			
		||||
            header.hide();
 | 
			
		||||
            if (typeof callback === 'function') callback();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { sizeOverride, onComplete, duration: animDuration } = options;
 | 
			
		||||
        const start = win.getBounds();
 | 
			
		||||
        const startTime = Date.now();
 | 
			
		||||
        const duration = animDuration || this.animationDuration;
 | 
			
		||||
        const { width, height } = sizeOverride || start;
 | 
			
		||||
 | 
			
		||||
        const step = () => {
 | 
			
		||||
            if (!this._isWindowValid(win)) {
 | 
			
		||||
                if (onComplete) onComplete();
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const p = Math.min((Date.now() - startTime) / duration, 1);
 | 
			
		||||
            const eased = 1 - Math.pow(1 - p, 3); // ease-out-cubic
 | 
			
		||||
            const x = start.x + (targetX - start.x) * eased;
 | 
			
		||||
            const y = start.y + (targetY - start.y) * eased;
 | 
			
		||||
 | 
			
		||||
            win.setBounds({ x: Math.round(x), y: Math.round(y), width, height });
 | 
			
		||||
 | 
			
		||||
            if (p < 1) {
 | 
			
		||||
                setTimeout(step, 8);
 | 
			
		||||
            } else {
 | 
			
		||||
                this.layoutManager.updateLayout();
 | 
			
		||||
                if (onComplete) {
 | 
			
		||||
                    onComplete();
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        step();
 | 
			
		||||
        header.webContents.send('window-hide-animation');
 | 
			
		||||
      
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            if (!header.isDestroyed()) header.hide();
 | 
			
		||||
            if (typeof callback === 'function') callback();
 | 
			
		||||
        }, 5);
 | 
			
		||||
    }
 | 
			
		||||
      
 | 
			
		||||
    showFromEdge(callback) {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (!header || header.isDestroyed()) {
 | 
			
		||||
            if (typeof callback === 'function') callback();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        // 숨기기 전에 기억해둔 위치 복구
 | 
			
		||||
        if (this.lastVisiblePosition) {
 | 
			
		||||
            header.setPosition(
 | 
			
		||||
                this.lastVisiblePosition.x,
 | 
			
		||||
                this.lastVisiblePosition.y,
 | 
			
		||||
                false   // animate: false
 | 
			
		||||
            );
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        header.show();
 | 
			
		||||
        header.webContents.send('window-show-animation');
 | 
			
		||||
      
 | 
			
		||||
        // 내부 상태 초기화
 | 
			
		||||
        this.hiddenPosition      = null;
 | 
			
		||||
        this.lastVisiblePosition = null;
 | 
			
		||||
      
 | 
			
		||||
        if (typeof callback === 'function') callback();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fade(win, { from, to, duration = 250, onComplete }) {
 | 
			
		||||
        if (!this._isWindowValid(win)) {
 | 
			
		||||
          if (onComplete) onComplete();
 | 
			
		||||
          return;
 | 
			
		||||
    moveStep(direction) {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
 | 
			
		||||
 | 
			
		||||
        const currentBounds = header.getBounds();
 | 
			
		||||
        this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
 | 
			
		||||
        let targetX = this.headerPosition.x;
 | 
			
		||||
        let targetY = this.headerPosition.y;
 | 
			
		||||
 | 
			
		||||
        console.log(`[MovementManager] Moving ${direction} from (${targetX}, ${targetY})`);
 | 
			
		||||
 | 
			
		||||
        const windowSize = {
 | 
			
		||||
            width: currentBounds.width,
 | 
			
		||||
            height: currentBounds.height
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        switch (direction) {
 | 
			
		||||
            case 'left': targetX -= this.stepSize; break;
 | 
			
		||||
            case 'right': targetX += this.stepSize; break;
 | 
			
		||||
            case 'up': targetY -= this.stepSize; break;
 | 
			
		||||
            case 'down': targetY += this.stepSize; break;
 | 
			
		||||
            default: return;
 | 
			
		||||
        }
 | 
			
		||||
        const startOpacity = from ?? win.getOpacity();
 | 
			
		||||
        const startTime = Date.now();
 | 
			
		||||
 | 
			
		||||
        // Find the display that contains or is nearest to the target position
 | 
			
		||||
        const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
 | 
			
		||||
        const { x: workAreaX, y: workAreaY, width: workAreaWidth, height: workAreaHeight } = nearestDisplay.workArea;
 | 
			
		||||
        
 | 
			
		||||
        const step = () => {
 | 
			
		||||
            if (!this._isWindowValid(win)) {
 | 
			
		||||
                if (onComplete) onComplete(); return;
 | 
			
		||||
            }
 | 
			
		||||
            const progress = Math.min(1, (Date.now() - startTime) / duration);
 | 
			
		||||
            const eased = 1 - Math.pow(1 - progress, 3);
 | 
			
		||||
            win.setOpacity(startOpacity + (to - startOpacity) * eased);
 | 
			
		||||
    
 | 
			
		||||
            if (progress < 1) {
 | 
			
		||||
                setTimeout(step, 8);
 | 
			
		||||
            } else {
 | 
			
		||||
                win.setOpacity(to);
 | 
			
		||||
                if (onComplete) onComplete();
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        step();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    animateWindowBounds(win, targetBounds, options = {}) {
 | 
			
		||||
        if (this.animationTimers.has(win)) {
 | 
			
		||||
            clearTimeout(this.animationTimers.get(win));
 | 
			
		||||
        // Only clamp if the target position would actually go out of bounds
 | 
			
		||||
        let clampedX = targetX;
 | 
			
		||||
        let clampedY = targetY;
 | 
			
		||||
        
 | 
			
		||||
        // Check horizontal bounds
 | 
			
		||||
        if (targetX < workAreaX) {
 | 
			
		||||
            clampedX = workAreaX;
 | 
			
		||||
        } else if (targetX + currentBounds.width > workAreaX + workAreaWidth) {
 | 
			
		||||
            clampedX = workAreaX + workAreaWidth - currentBounds.width;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // Check vertical bounds
 | 
			
		||||
        if (targetY < workAreaY) {
 | 
			
		||||
            clampedY = workAreaY;
 | 
			
		||||
            console.log(`[MovementManager] Clamped Y to top edge: ${clampedY}`);
 | 
			
		||||
        } else if (targetY + currentBounds.height > workAreaY + workAreaHeight) {
 | 
			
		||||
            clampedY = workAreaY + workAreaHeight - currentBounds.height;
 | 
			
		||||
            console.log(`[MovementManager] Clamped Y to bottom edge: ${clampedY}`);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!this._isWindowValid(win)) {
 | 
			
		||||
            if (options.onComplete) options.onComplete();
 | 
			
		||||
        console.log(`[MovementManager] Final position: (${clampedX}, ${clampedY}), Work area: ${workAreaX},${workAreaY} ${workAreaWidth}x${workAreaHeight}`);
 | 
			
		||||
 | 
			
		||||
        // Only move if there's an actual change in position
 | 
			
		||||
        if (clampedX === this.headerPosition.x && clampedY === this.headerPosition.y) {
 | 
			
		||||
            console.log(`[MovementManager] No position change, skipping animation`);
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this.animateToPosition(header, clampedX, clampedY, windowSize);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    animateToPosition(header, targetX, targetY, windowSize) {
 | 
			
		||||
        if (!this._isWindowValid(header)) return;
 | 
			
		||||
        
 | 
			
		||||
        this.isAnimating = true;
 | 
			
		||||
        const startX = this.headerPosition.x;
 | 
			
		||||
        const startY = this.headerPosition.y;
 | 
			
		||||
        const startTime = Date.now();
 | 
			
		||||
 | 
			
		||||
        if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
 | 
			
		||||
            this.isAnimating = false;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        this.isAnimating = true;
 | 
			
		||||
        const animate = () => {
 | 
			
		||||
            if (!this._isWindowValid(header)) return;
 | 
			
		||||
 | 
			
		||||
        const startBounds = win.getBounds();
 | 
			
		||||
        const startTime = Date.now();
 | 
			
		||||
        const duration = options.duration || this.animationDuration;
 | 
			
		||||
    
 | 
			
		||||
        const step = () => {
 | 
			
		||||
            if (!this._isWindowValid(win)) {
 | 
			
		||||
                if (options.onComplete) options.onComplete();
 | 
			
		||||
            const elapsed = Date.now() - startTime;
 | 
			
		||||
            const progress = Math.min(elapsed / this.animationDuration, 1);
 | 
			
		||||
            const eased = 1 - Math.pow(1 - progress, 3);
 | 
			
		||||
            const currentX = startX + (targetX - startX) * eased;
 | 
			
		||||
            const currentY = startY + (targetY - startY) * eased;
 | 
			
		||||
 | 
			
		||||
            if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
 | 
			
		||||
                this.isAnimating = false;
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            const progress = Math.min(1, (Date.now() - startTime) / duration);
 | 
			
		||||
            const eased = 1 - Math.pow(1 - progress, 3);
 | 
			
		||||
    
 | 
			
		||||
            const newBounds = {
 | 
			
		||||
                x: Math.round(startBounds.x + (targetBounds.x - startBounds.x) * eased),
 | 
			
		||||
                y: Math.round(startBounds.y + (targetBounds.y - startBounds.y) * eased),
 | 
			
		||||
                width: Math.round(startBounds.width + ((targetBounds.width ?? startBounds.width) - startBounds.width) * eased),
 | 
			
		||||
                height: Math.round(startBounds.height + ((targetBounds.height ?? startBounds.height) - startBounds.height) * eased),
 | 
			
		||||
            };
 | 
			
		||||
            win.setBounds(newBounds);
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
            if (!this._isWindowValid(header)) return;
 | 
			
		||||
            const { width, height } = windowSize || header.getBounds();
 | 
			
		||||
            header.setBounds({
 | 
			
		||||
                x: Math.round(currentX),
 | 
			
		||||
                y: Math.round(currentY),
 | 
			
		||||
                width,
 | 
			
		||||
                height
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            if (progress < 1) {
 | 
			
		||||
                const timerId = setTimeout(step, 8);
 | 
			
		||||
                this.animationTimers.set(win, timerId);
 | 
			
		||||
                this.animationFrameId = setTimeout(animate, 8);
 | 
			
		||||
            } else {
 | 
			
		||||
                win.setBounds(targetBounds);
 | 
			
		||||
                this.animationTimers.delete(win);
 | 
			
		||||
                
 | 
			
		||||
                if (this.animationTimers.size === 0) {
 | 
			
		||||
                    this.isAnimating = false;
 | 
			
		||||
                this.animationFrameId = null;
 | 
			
		||||
                this.isAnimating = false;
 | 
			
		||||
                if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
 | 
			
		||||
                    if (!this._isWindowValid(header)) return;
 | 
			
		||||
                    header.setPosition(Math.round(targetX), Math.round(targetY));
 | 
			
		||||
                    // Update header position to the actual final position
 | 
			
		||||
                    this.headerPosition = { x: Math.round(targetX), y: Math.round(targetY) };
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
                if (options.onComplete) options.onComplete();
 | 
			
		||||
                this.updateLayout();
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        step();
 | 
			
		||||
        animate();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    animateWindowPosition(win, targetPosition, options = {}) {
 | 
			
		||||
        if (!this._isWindowValid(win)) {
 | 
			
		||||
            if (options.onComplete) options.onComplete();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const currentBounds = win.getBounds();
 | 
			
		||||
        const targetBounds = { ...currentBounds, ...targetPosition };
 | 
			
		||||
        this.animateWindowBounds(win, targetBounds, options);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    animateLayout(layout, animated = true) {
 | 
			
		||||
        if (!layout) return;
 | 
			
		||||
        for (const winName in layout) {
 | 
			
		||||
            const win = this.windowPool.get(winName);
 | 
			
		||||
            const targetBounds = layout[winName];
 | 
			
		||||
            if (win && !win.isDestroyed() && targetBounds) {
 | 
			
		||||
                if (animated) {
 | 
			
		||||
                    this.animateWindowBounds(win, targetBounds);
 | 
			
		||||
                } else {
 | 
			
		||||
                    win.setBounds(targetBounds);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
    moveToEdge(direction) {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
 | 
			
		||||
 | 
			
		||||
        const display = this.getCurrentDisplay(header);
 | 
			
		||||
        const { width, height } = display.workAreaSize;
 | 
			
		||||
        const { x: workAreaX, y: workAreaY } = display.workArea;
 | 
			
		||||
        const currentBounds = header.getBounds();
 | 
			
		||||
        
 | 
			
		||||
        const windowSize = {
 | 
			
		||||
            width: currentBounds.width,
 | 
			
		||||
            height: currentBounds.height
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        let targetX = currentBounds.x;
 | 
			
		||||
        let targetY = currentBounds.y;
 | 
			
		||||
 | 
			
		||||
        switch (direction) {
 | 
			
		||||
            case 'left': 
 | 
			
		||||
                targetX = workAreaX; 
 | 
			
		||||
                break;
 | 
			
		||||
            case 'right': 
 | 
			
		||||
                targetX = workAreaX + width - windowSize.width; 
 | 
			
		||||
                break;
 | 
			
		||||
            case 'up': 
 | 
			
		||||
                targetY = workAreaY; 
 | 
			
		||||
                break;
 | 
			
		||||
            case 'down': 
 | 
			
		||||
                targetY = workAreaY + height - windowSize.height; 
 | 
			
		||||
                break;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        header.setBounds({
 | 
			
		||||
            x: Math.round(targetX),
 | 
			
		||||
            y: Math.round(targetY),
 | 
			
		||||
            width: windowSize.width,
 | 
			
		||||
            height: windowSize.height
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        this.headerPosition = { x: targetX, y: targetY };
 | 
			
		||||
        this.updateLayout();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    destroy() {
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
const { screen } = require('electron');
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * 
 | 
			
		||||
 * @param {BrowserWindow} window 
 | 
			
		||||
 * @returns {Display}
 | 
			
		||||
 * 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다.
 | 
			
		||||
 * @param {BrowserWindow} window - 확인할 창 객체
 | 
			
		||||
 * @returns {Display} Electron의 Display 객체
 | 
			
		||||
 */
 | 
			
		||||
function getCurrentDisplay(window) {
 | 
			
		||||
    if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
 | 
			
		||||
@ -27,28 +27,53 @@ class WindowLayoutManager {
 | 
			
		||||
        this.PADDING = 80;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getHeaderPosition = () => {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (header) {
 | 
			
		||||
            const [x, y] = header.getPosition();
 | 
			
		||||
            return { x, y };
 | 
			
		||||
        }
 | 
			
		||||
        return { x: 0, y: 0 };
 | 
			
		||||
    };
 | 
			
		||||
    /**
 | 
			
		||||
     * 모든 창의 레이아웃 업데이트를 요청합니다.
 | 
			
		||||
     * 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
 | 
			
		||||
     */
 | 
			
		||||
    updateLayout() {
 | 
			
		||||
        if (this.isUpdating) return;
 | 
			
		||||
        this.isUpdating = true;
 | 
			
		||||
 | 
			
		||||
        setImmediate(() => {
 | 
			
		||||
            this.positionWindows();
 | 
			
		||||
            this.isUpdating = false;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @returns {{name: string, primary: string, secondary: string}}
 | 
			
		||||
     * 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
 | 
			
		||||
     */
 | 
			
		||||
    determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY) {
 | 
			
		||||
        const headerRelX = headerBounds.x - workAreaX;
 | 
			
		||||
        const headerRelY = headerBounds.y - workAreaY;
 | 
			
		||||
    positionWindows() {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        if (!header?.getBounds) return;
 | 
			
		||||
 | 
			
		||||
        const spaceBelow = screenHeight - (headerRelY + headerBounds.height);
 | 
			
		||||
        const spaceAbove = headerRelY;
 | 
			
		||||
        const spaceLeft = headerRelX;
 | 
			
		||||
        const spaceRight = screenWidth - (headerRelX + headerBounds.width);
 | 
			
		||||
        const headerBounds = header.getBounds();
 | 
			
		||||
        const display = getCurrentDisplay(header);
 | 
			
		||||
        const { width: screenWidth, height: screenHeight } = display.workAreaSize;
 | 
			
		||||
        const { x: workAreaX, y: workAreaY } = display.workArea;
 | 
			
		||||
 | 
			
		||||
        const headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
 | 
			
		||||
        const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
 | 
			
		||||
 | 
			
		||||
        const relativeX = headerCenterX / screenWidth;
 | 
			
		||||
        const relativeY = headerCenterY / screenHeight;
 | 
			
		||||
 | 
			
		||||
        const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY);
 | 
			
		||||
 | 
			
		||||
        this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
 | 
			
		||||
        this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 헤더 창의 위치에 따라 기능 창들을 배치할 최적의 전략을 결정합니다.
 | 
			
		||||
     * @returns {{name: string, primary: string, secondary: string}} 레이아웃 전략
 | 
			
		||||
     */
 | 
			
		||||
    determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY) {
 | 
			
		||||
        const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height);
 | 
			
		||||
        const spaceAbove = headerBounds.y;
 | 
			
		||||
        const spaceLeft = headerBounds.x;
 | 
			
		||||
        const spaceRight = screenWidth - (headerBounds.x + headerBounds.width);
 | 
			
		||||
 | 
			
		||||
        if (spaceBelow >= 400) {
 | 
			
		||||
            return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' };
 | 
			
		||||
@ -63,242 +88,120 @@ class WindowLayoutManager {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * @returns {{x: number, y: number} | null}
 | 
			
		||||
     * 'ask'와 'listen' 창의 위치를 조정합니다.
 | 
			
		||||
     */
 | 
			
		||||
    calculateSettingsWindowPosition() {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        const settings = this.windowPool.get('settings');
 | 
			
		||||
 | 
			
		||||
        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 = 170;
 | 
			
		||||
 | 
			
		||||
        const x = headerBounds.x + headerBounds.width - settingsBounds.width + buttonPadding;
 | 
			
		||||
        const y = headerBounds.y + headerBounds.height + PAD;
 | 
			
		||||
 | 
			
		||||
        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));
 | 
			
		||||
 | 
			
		||||
        return { x: Math.round(clampedX), y: Math.round(clampedY) };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    calculateHeaderResize(header, { width, height }) {
 | 
			
		||||
        if (!header) return null;
 | 
			
		||||
        const currentBounds = header.getBounds();
 | 
			
		||||
        const centerX = currentBounds.x + currentBounds.width / 2;
 | 
			
		||||
        const newX = Math.round(centerX - width / 2);
 | 
			
		||||
        const display = getCurrentDisplay(header);
 | 
			
		||||
        const { x: workAreaX, width: workAreaWidth } = display.workArea;
 | 
			
		||||
        const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX));
 | 
			
		||||
        return { x: clampedX, y: currentBounds.y, width, height };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    calculateClampedPosition(header, { x: newX, y: newY }) {
 | 
			
		||||
        if (!header) return null;
 | 
			
		||||
        const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
 | 
			
		||||
        const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
 | 
			
		||||
        const headerBounds = header.getBounds();
 | 
			
		||||
        const clampedX = Math.max(workAreaX, Math.min(newX, workAreaX + width - headerBounds.width));
 | 
			
		||||
        const clampedY = Math.max(workAreaY, Math.min(newY, workAreaY + height - headerBounds.height));
 | 
			
		||||
        return { x: clampedX, y: clampedY };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    calculateWindowHeightAdjustment(senderWindow, targetHeight) {
 | 
			
		||||
        if (!senderWindow) return null;
 | 
			
		||||
        const currentBounds = senderWindow.getBounds();
 | 
			
		||||
        const minHeight = senderWindow.getMinimumSize()[1];
 | 
			
		||||
        const maxHeight = senderWindow.getMaximumSize()[1];
 | 
			
		||||
        let adjustedHeight = Math.max(minHeight, targetHeight);
 | 
			
		||||
        if (maxHeight > 0) {
 | 
			
		||||
            adjustedHeight = Math.min(maxHeight, adjustedHeight);
 | 
			
		||||
        }
 | 
			
		||||
        console.log(`[Layout Debug] calculateWindowHeightAdjustment: targetHeight=${targetHeight}`);
 | 
			
		||||
        return { ...currentBounds, height: adjustedHeight };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // 기존 getTargetBoundsForFeatureWindows를 이 함수로 대체합니다.
 | 
			
		||||
    calculateFeatureWindowLayout(visibility, headerBoundsOverride = null) {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        const headerBounds = headerBoundsOverride || (header ? header.getBounds() : null);
 | 
			
		||||
 | 
			
		||||
        if (!headerBounds) return {};
 | 
			
		||||
 | 
			
		||||
        let display;
 | 
			
		||||
        if (headerBoundsOverride) {
 | 
			
		||||
            const boundsCenter = {
 | 
			
		||||
                x: headerBounds.x + headerBounds.width / 2,
 | 
			
		||||
                y: headerBounds.y + headerBounds.height / 2,
 | 
			
		||||
            };
 | 
			
		||||
            display = screen.getDisplayNearestPoint(boundsCenter);
 | 
			
		||||
        } else {
 | 
			
		||||
            display = getCurrentDisplay(header);
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        const { width: screenWidth, height: screenHeight, x: workAreaX, y: workAreaY } = display.workArea;
 | 
			
		||||
    
 | 
			
		||||
    positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
 | 
			
		||||
        const ask = this.windowPool.get('ask');
 | 
			
		||||
        const listen = this.windowPool.get('listen');
 | 
			
		||||
    
 | 
			
		||||
        const askVis = visibility.ask && ask && !ask.isDestroyed();
 | 
			
		||||
        const listenVis = visibility.listen && listen && !listen.isDestroyed();
 | 
			
		||||
    
 | 
			
		||||
        if (!askVis && !listenVis) return {};
 | 
			
		||||
    
 | 
			
		||||
        const PAD = 8;
 | 
			
		||||
        const headerTopRel = headerBounds.y - workAreaY;
 | 
			
		||||
        const headerBottomRel = headerTopRel + headerBounds.height;
 | 
			
		||||
        const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
 | 
			
		||||
        
 | 
			
		||||
        const relativeX = headerCenterXRel / screenWidth;
 | 
			
		||||
        const relativeY = (headerBounds.y - workAreaY) / screenHeight;
 | 
			
		||||
        const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY);
 | 
			
		||||
    
 | 
			
		||||
        const askB = askVis ? ask.getBounds() : null;
 | 
			
		||||
        const listenB = listenVis ? listen.getBounds() : null;
 | 
			
		||||
        const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
 | 
			
		||||
        const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
 | 
			
		||||
 | 
			
		||||
        if (!askVisible && !listenVisible) return;
 | 
			
		||||
 | 
			
		||||
        const PAD = 8;
 | 
			
		||||
        const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
 | 
			
		||||
        let askBounds = askVisible ? ask.getBounds() : null;
 | 
			
		||||
        let listenBounds = listenVisible ? listen.getBounds() : null;
 | 
			
		||||
 | 
			
		||||
        if (askVisible && listenVisible) {
 | 
			
		||||
            const combinedWidth = listenBounds.width + PAD + askBounds.width;
 | 
			
		||||
            let groupStartXRel = headerCenterXRel - combinedWidth / 2;
 | 
			
		||||
            let listenXRel = groupStartXRel;
 | 
			
		||||
            let askXRel = groupStartXRel + listenBounds.width + PAD;
 | 
			
		||||
 | 
			
		||||
        if (askVis) {
 | 
			
		||||
            console.log(`[Layout Debug] Ask Window Bounds: height=${askB.height}, width=${askB.width}`);
 | 
			
		||||
        }
 | 
			
		||||
        if (listenVis) {
 | 
			
		||||
            console.log(`[Layout Debug] Listen Window Bounds: height=${listenB.height}, width=${listenB.width}`);
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        const layout = {};
 | 
			
		||||
    
 | 
			
		||||
        if (askVis && listenVis) {
 | 
			
		||||
            let askXRel = headerCenterXRel - (askB.width / 2);
 | 
			
		||||
            let listenXRel = askXRel - listenB.width - PAD;
 | 
			
		||||
    
 | 
			
		||||
            if (listenXRel < PAD) {
 | 
			
		||||
                listenXRel = PAD;
 | 
			
		||||
                askXRel = listenXRel + listenB.width + PAD;
 | 
			
		||||
                askXRel = listenXRel + listenBounds.width + PAD;
 | 
			
		||||
            }
 | 
			
		||||
            if (askXRel + askB.width > screenWidth - PAD) {
 | 
			
		||||
                askXRel = screenWidth - PAD - askB.width;
 | 
			
		||||
                listenXRel = askXRel - listenB.width - PAD;
 | 
			
		||||
            if (askXRel + askBounds.width > screenWidth - PAD) {
 | 
			
		||||
                askXRel = screenWidth - PAD - askBounds.width;
 | 
			
		||||
                listenXRel = askXRel - listenBounds.width - PAD;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if (strategy.primary === 'above') {
 | 
			
		||||
                const windowBottomAbs = headerBounds.y - PAD;
 | 
			
		||||
                layout.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(windowBottomAbs - askB.height), width: askB.width, height: askB.height };
 | 
			
		||||
                layout.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(windowBottomAbs - listenB.height), width: listenB.width, height: listenB.height };
 | 
			
		||||
            } else { // 'below'
 | 
			
		||||
                const yAbs = headerBounds.y + headerBounds.height + PAD;
 | 
			
		||||
                layout.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(yAbs), width: askB.width, height: askB.height };
 | 
			
		||||
                layout.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs), width: listenB.width, height: listenB.height };
 | 
			
		||||
            }
 | 
			
		||||
        } else { // Single window
 | 
			
		||||
            const winName = askVis ? 'ask' : 'listen';
 | 
			
		||||
            const winB = askVis ? askB : listenB;
 | 
			
		||||
            if (!winB) return {};
 | 
			
		||||
    
 | 
			
		||||
            let xRel = headerCenterXRel - winB.width / 2;
 | 
			
		||||
            xRel = Math.max(PAD, Math.min(screenWidth - winB.width - PAD, xRel));
 | 
			
		||||
    
 | 
			
		||||
            let yPos;
 | 
			
		||||
            if (strategy.primary === 'above') {
 | 
			
		||||
                yPos = (headerBounds.y - workAreaY) - PAD - winB.height;
 | 
			
		||||
            } else { // 'below'
 | 
			
		||||
                yPos = (headerBounds.y - workAreaY) + headerBounds.height + PAD;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            layout[winName] = { x: Math.round(xRel + workAreaX), y: Math.round(yPos + workAreaY), width: winB.width, height: winB.height };
 | 
			
		||||
 | 
			
		||||
            let yRel = (strategy.primary === 'above')
 | 
			
		||||
                ? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD
 | 
			
		||||
                : headerBounds.y - workAreaY + headerBounds.height + PAD;
 | 
			
		||||
 | 
			
		||||
            listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yRel + workAreaY), width: listenBounds.width, height: listenBounds.height });
 | 
			
		||||
            ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yRel + workAreaY), width: askBounds.width, height: askBounds.height });
 | 
			
		||||
        } else {
 | 
			
		||||
            const win = askVisible ? ask : listen;
 | 
			
		||||
            const winBounds = askVisible ? askBounds : listenBounds;
 | 
			
		||||
            let xRel = headerCenterXRel - winBounds.width / 2;
 | 
			
		||||
            let yRel = (strategy.primary === 'above')
 | 
			
		||||
                ? headerBounds.y - workAreaY - winBounds.height - PAD
 | 
			
		||||
                : headerBounds.y - workAreaY + headerBounds.height + PAD;
 | 
			
		||||
 | 
			
		||||
            xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
 | 
			
		||||
            yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
 | 
			
		||||
 | 
			
		||||
            win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), width: winBounds.width, height: winBounds.height });
 | 
			
		||||
        }
 | 
			
		||||
        return layout;
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    calculateShortcutSettingsWindowPosition() {
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        const shortcutSettings = this.windowPool.get('shortcut-settings');
 | 
			
		||||
        if (!header || !shortcutSettings) return null;
 | 
			
		||||
    
 | 
			
		||||
        const headerBounds = header.getBounds();
 | 
			
		||||
        const shortcutBounds = shortcutSettings.getBounds();
 | 
			
		||||
        const { workArea } = getCurrentDisplay(header);
 | 
			
		||||
    
 | 
			
		||||
        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));
 | 
			
		||||
    
 | 
			
		||||
        return { x: newX, y: newY, width: shortcutBounds.width, height: shortcutBounds.height };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    calculateStepMovePosition(header, direction) {
 | 
			
		||||
        if (!header) return null;
 | 
			
		||||
        const currentBounds = header.getBounds();
 | 
			
		||||
        const stepSize = 80; // 이동 간격
 | 
			
		||||
        let targetX = currentBounds.x;
 | 
			
		||||
        let targetY = currentBounds.y;
 | 
			
		||||
    
 | 
			
		||||
        switch (direction) {
 | 
			
		||||
            case 'left': targetX -= stepSize; break;
 | 
			
		||||
            case 'right': targetX += stepSize; break;
 | 
			
		||||
            case 'up': targetY -= stepSize; break;
 | 
			
		||||
            case 'down': targetY += stepSize; break;
 | 
			
		||||
    /**
 | 
			
		||||
     * 'settings' 창의 위치를 조정합니다.
 | 
			
		||||
     */
 | 
			
		||||
    positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
 | 
			
		||||
        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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    
 | 
			
		||||
        return this.calculateClampedPosition(header, { x: targetX, y: targetY });
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    calculateEdgePosition(header, direction) {
 | 
			
		||||
        if (!header) return null;
 | 
			
		||||
        const display = getCurrentDisplay(header);
 | 
			
		||||
        const { workArea } = display;
 | 
			
		||||
        const currentBounds = header.getBounds();
 | 
			
		||||
    
 | 
			
		||||
        let targetX = currentBounds.x;
 | 
			
		||||
        let targetY = currentBounds.y;
 | 
			
		||||
    
 | 
			
		||||
        switch (direction) {
 | 
			
		||||
            case 'left': targetX = workArea.x; break;
 | 
			
		||||
            case 'right': targetX = workArea.x + workArea.width - currentBounds.width; break;
 | 
			
		||||
            case 'up': targetY = workArea.y; break;
 | 
			
		||||
            case 'down': targetY = workArea.y + workArea.height - currentBounds.height; break;
 | 
			
		||||
 | 
			
		||||
        const settingsBounds = settings.getBounds();
 | 
			
		||||
        const PAD = 5;
 | 
			
		||||
        const buttonPadding = 17;
 | 
			
		||||
        let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
 | 
			
		||||
        let y = headerBounds.y + headerBounds.height + PAD;
 | 
			
		||||
 | 
			
		||||
        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 settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height };
 | 
			
		||||
        let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds));
 | 
			
		||||
 | 
			
		||||
        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: targetX, y: targetY };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    calculateNewPositionForDisplay(window, targetDisplayId) {
 | 
			
		||||
        if (!window) return null;
 | 
			
		||||
    
 | 
			
		||||
        const targetDisplay = screen.getAllDisplays().find(d => d.id === targetDisplayId);
 | 
			
		||||
        if (!targetDisplay) return null;
 | 
			
		||||
    
 | 
			
		||||
        const currentBounds = window.getBounds();
 | 
			
		||||
        const currentDisplay = getCurrentDisplay(window);
 | 
			
		||||
    
 | 
			
		||||
        if (currentDisplay.id === targetDisplay.id) return { x: currentBounds.x, y: currentBounds.y };
 | 
			
		||||
    
 | 
			
		||||
        const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workArea.width;
 | 
			
		||||
        const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workArea.height;
 | 
			
		||||
        
 | 
			
		||||
        const targetX = targetDisplay.workArea.x + targetDisplay.workArea.width * relativeX;
 | 
			
		||||
        const targetY = targetDisplay.workArea.y + targetDisplay.workArea.height * relativeY;
 | 
			
		||||
    
 | 
			
		||||
        const clampedX = Math.max(targetDisplay.workArea.x, Math.min(targetX, targetDisplay.workArea.x + targetDisplay.workArea.width - currentBounds.width));
 | 
			
		||||
        const clampedY = Math.max(targetDisplay.workArea.y, Math.min(targetY, targetDisplay.workArea.y + targetDisplay.workArea.height - currentBounds.height));
 | 
			
		||||
    
 | 
			
		||||
        return { x: Math.round(clampedX), y: Math.round(clampedY) };
 | 
			
		||||
 | 
			
		||||
        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));
 | 
			
		||||
 | 
			
		||||
        settings.setBounds({ x: Math.round(x), y: Math.round(y) });
 | 
			
		||||
        settings.moveTop();
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    /**
 | 
			
		||||
     * 두 사각형 영역이 겹치는지 확인합니다.
 | 
			
		||||
     * @param {Rectangle} bounds1
 | 
			
		||||
     * @param {Rectangle} bounds2
 | 
			
		||||
     * @returns {boolean}
 | 
			
		||||
     * @returns {boolean} 겹침 여부
 | 
			
		||||
     */
 | 
			
		||||
    boundsOverlap(bounds1, bounds2) {
 | 
			
		||||
        const margin = 10;
 | 
			
		||||
 | 
			
		||||
@ -29,382 +29,31 @@ 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;
 | 
			
		||||
 | 
			
		||||
let currentHeaderState = 'apikey';
 | 
			
		||||
const windowPool = new Map();
 | 
			
		||||
let fixedYPosition = 0;
 | 
			
		||||
 | 
			
		||||
let settingsHideTimer = null;
 | 
			
		||||
 | 
			
		||||
let selectedCaptureSourceId = null;
 | 
			
		||||
 | 
			
		||||
// let shortcutEditorWindow = null;
 | 
			
		||||
let layoutManager = null;
 | 
			
		||||
function updateLayout() {
 | 
			
		||||
    if (layoutManager) {
 | 
			
		||||
        layoutManager.updateLayout();
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
let movementManager = null;
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function updateChildWindowLayouts(animated = true) {
 | 
			
		||||
    // if (movementManager.isAnimating) return;
 | 
			
		||||
 | 
			
		||||
    const visibleWindows = {};
 | 
			
		||||
    const listenWin = windowPool.get('listen');
 | 
			
		||||
    const askWin = windowPool.get('ask');
 | 
			
		||||
    if (listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) {
 | 
			
		||||
        visibleWindows.listen = true;
 | 
			
		||||
    }
 | 
			
		||||
    if (askWin && !askWin.isDestroyed() && askWin.isVisible()) {
 | 
			
		||||
        visibleWindows.ask = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (Object.keys(visibleWindows).length === 0) return;
 | 
			
		||||
 | 
			
		||||
    const newLayout = layoutManager.calculateFeatureWindowLayout(visibleWindows);
 | 
			
		||||
    movementManager.animateLayout(newLayout, animated);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const showSettingsWindow = () => {
 | 
			
		||||
    internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const hideSettingsWindow = () => {
 | 
			
		||||
    internalBridge.emit('window:requestVisibility', { name: 'settings', visible: false });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const cancelHideSettingsWindow = () => {
 | 
			
		||||
    internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const moveWindowStep = (direction) => {
 | 
			
		||||
    internalBridge.emit('window:moveStep', { direction });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const resizeHeaderWindow = ({ width, height }) => {
 | 
			
		||||
    internalBridge.emit('window:resizeHeaderWindow', { width, height });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleHeaderAnimationFinished = (state) => {
 | 
			
		||||
    internalBridge.emit('window:headerAnimationFinished', state);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getHeaderPosition = () => {
 | 
			
		||||
    return new Promise((resolve) => {
 | 
			
		||||
        internalBridge.emit('window:getHeaderPosition', (position) => {
 | 
			
		||||
            resolve(position);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const moveHeaderTo = (newX, newY) => {
 | 
			
		||||
    internalBridge.emit('window:moveHeaderTo', { newX, newY });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const adjustWindowHeight = (winName, targetHeight) => {
 | 
			
		||||
    internalBridge.emit('window:adjustWindowHeight', { winName, targetHeight });
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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);
 | 
			
		||||
    });
 | 
			
		||||
    internalBridge.on('window:moveToDisplay', ({ displayId }) => {
 | 
			
		||||
        // movementManager.moveToDisplay(displayId);
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header) {
 | 
			
		||||
            const newPosition = layoutManager.calculateNewPositionForDisplay(header, displayId);
 | 
			
		||||
            if (newPosition) {
 | 
			
		||||
                movementManager.animateWindowPosition(header, newPosition, {
 | 
			
		||||
                    onComplete: () => updateChildWindowLayouts(true)
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    internalBridge.on('window:moveToEdge', ({ direction }) => {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header) {
 | 
			
		||||
            const newPosition = layoutManager.calculateEdgePosition(header, direction);
 | 
			
		||||
            movementManager.animateWindowPosition(header, newPosition, { 
 | 
			
		||||
                onComplete: () => updateChildWindowLayouts(true) 
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    internalBridge.on('window:moveStep', ({ direction }) => {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header) { 
 | 
			
		||||
            const newHeaderPosition = layoutManager.calculateStepMovePosition(header, direction);
 | 
			
		||||
            if (!newHeaderPosition) return;
 | 
			
		||||
    
 | 
			
		||||
            const futureHeaderBounds = { ...header.getBounds(), ...newHeaderPosition };
 | 
			
		||||
            const visibleWindows = {};
 | 
			
		||||
            const listenWin = windowPool.get('listen');
 | 
			
		||||
            const askWin = windowPool.get('ask');
 | 
			
		||||
            if (listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) {
 | 
			
		||||
                visibleWindows.listen = true;
 | 
			
		||||
            }
 | 
			
		||||
            if (askWin && !askWin.isDestroyed() && askWin.isVisible()) {
 | 
			
		||||
                visibleWindows.ask = true;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const newChildLayout = layoutManager.calculateFeatureWindowLayout(visibleWindows, futureHeaderBounds);
 | 
			
		||||
    
 | 
			
		||||
            movementManager.animateWindowPosition(header, newHeaderPosition);
 | 
			
		||||
            movementManager.animateLayout(newChildLayout);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    internalBridge.on('window:resizeHeaderWindow', ({ width, height }) => {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (!header || movementManager.isAnimating) return;
 | 
			
		||||
 | 
			
		||||
        const newHeaderBounds = layoutManager.calculateHeaderResize(header, { width, height });
 | 
			
		||||
        
 | 
			
		||||
        const wasResizable = header.isResizable();
 | 
			
		||||
        if (!wasResizable) header.setResizable(true);
 | 
			
		||||
 | 
			
		||||
        movementManager.animateWindowBounds(header, newHeaderBounds, {
 | 
			
		||||
            onComplete: () => {
 | 
			
		||||
                if (!wasResizable) header.setResizable(false);
 | 
			
		||||
                updateChildWindowLayouts(true);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    internalBridge.on('window:headerAnimationFinished', (state) => {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (!header || header.isDestroyed()) return;
 | 
			
		||||
 | 
			
		||||
        if (state === 'hidden') {
 | 
			
		||||
            header.hide();
 | 
			
		||||
        } else if (state === 'visible') {
 | 
			
		||||
            updateChildWindowLayouts(false);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    internalBridge.on('window:getHeaderPosition', (reply) => {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header && !header.isDestroyed()) {
 | 
			
		||||
            reply(header.getBounds());
 | 
			
		||||
        } else {
 | 
			
		||||
            reply({ x: 0, y: 0, width: 0, height: 0 });
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    internalBridge.on('window:moveHeaderTo', ({ newX, newY }) => {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header) {
 | 
			
		||||
            const newPosition = layoutManager.calculateClampedPosition(header, { x: newX, y: newY });
 | 
			
		||||
            header.setPosition(newPosition.x, newPosition.y);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    internalBridge.on('window:adjustWindowHeight', ({ winName, targetHeight }) => {
 | 
			
		||||
        console.log(`[Layout Debug] adjustWindowHeight: targetHeight=${targetHeight}`);
 | 
			
		||||
        const senderWindow = windowPool.get(winName);
 | 
			
		||||
        if (senderWindow) {
 | 
			
		||||
            const newBounds = layoutManager.calculateWindowHeightAdjustment(senderWindow, targetHeight);
 | 
			
		||||
            
 | 
			
		||||
            const wasResizable = senderWindow.isResizable();
 | 
			
		||||
            if (!wasResizable) senderWindow.setResizable(true);
 | 
			
		||||
 | 
			
		||||
            movementManager.animateWindowBounds(senderWindow, newBounds, {
 | 
			
		||||
                onComplete: () => {
 | 
			
		||||
                    if (!wasResizable) senderWindow.setResizable(false);
 | 
			
		||||
                    updateChildWindowLayouts(true);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
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' | 'settings' | 'shortcut-settings'} name 
 | 
			
		||||
 * @param {boolean} shouldBeVisible 
 | 
			
		||||
 */
 | 
			
		||||
async function handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, shouldBeVisible) {
 | 
			
		||||
    console.log(`[WindowManager] Request: set '${name}' visibility to ${shouldBeVisible}`);
 | 
			
		||||
    const win = windowPool.get(name);
 | 
			
		||||
 | 
			
		||||
    if (!win || win.isDestroyed()) {
 | 
			
		||||
        console.warn(`[WindowManager] Window '${name}' not found or destroyed.`);
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    if (name === 'shortcut-settings') {
 | 
			
		||||
        if (shouldBeVisible) {
 | 
			
		||||
            // layoutManager.positionShortcutSettingsWindow();
 | 
			
		||||
            const newBounds = layoutManager.calculateShortcutSettingsWindowPosition();
 | 
			
		||||
            if (newBounds) win.setBounds(newBounds);
 | 
			
		||||
            
 | 
			
		||||
            if (process.platform === 'darwin') {
 | 
			
		||||
                win.setAlwaysOnTop(true, 'screen-saver');
 | 
			
		||||
            } else {
 | 
			
		||||
                win.setAlwaysOnTop(true);
 | 
			
		||||
            }
 | 
			
		||||
            // globalShortcut.unregisterAll();
 | 
			
		||||
            disableClicks(win);
 | 
			
		||||
            win.show();
 | 
			
		||||
        } else {
 | 
			
		||||
            if (process.platform === 'darwin') {
 | 
			
		||||
                win.setAlwaysOnTop(false, 'screen-saver');
 | 
			
		||||
            } else {
 | 
			
		||||
                win.setAlwaysOnTop(false);
 | 
			
		||||
            }
 | 
			
		||||
            restoreClicks();
 | 
			
		||||
            win.hide();
 | 
			
		||||
        }
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (name === 'listen' || name === 'ask') {
 | 
			
		||||
        const win = windowPool.get(name);
 | 
			
		||||
        const otherName = name === 'listen' ? 'ask' : 'listen';
 | 
			
		||||
        const otherWin = windowPool.get(otherName);
 | 
			
		||||
        const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible();
 | 
			
		||||
        
 | 
			
		||||
        const ANIM_OFFSET_X = 50;
 | 
			
		||||
        const ANIM_OFFSET_Y = 20;
 | 
			
		||||
 | 
			
		||||
        const finalVisibility = {
 | 
			
		||||
            listen: (name === 'listen' && shouldBeVisible) || (otherName === 'listen' && isOtherWinVisible),
 | 
			
		||||
            ask: (name === 'ask' && shouldBeVisible) || (otherName === 'ask' && isOtherWinVisible),
 | 
			
		||||
        };
 | 
			
		||||
        if (!shouldBeVisible) {
 | 
			
		||||
            finalVisibility[name] = false;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const targetLayout = layoutManager.calculateFeatureWindowLayout(finalVisibility);
 | 
			
		||||
 | 
			
		||||
        if (shouldBeVisible) {
 | 
			
		||||
            if (!win) return;
 | 
			
		||||
            const targetBounds = targetLayout[name];
 | 
			
		||||
            if (!targetBounds) return;
 | 
			
		||||
 | 
			
		||||
            const startPos = { ...targetBounds };
 | 
			
		||||
            if (name === 'listen') startPos.x -= ANIM_OFFSET_X;
 | 
			
		||||
            else if (name === 'ask') startPos.y -= ANIM_OFFSET_Y;
 | 
			
		||||
 | 
			
		||||
            win.setOpacity(0);
 | 
			
		||||
            win.setBounds(startPos);
 | 
			
		||||
            win.show();
 | 
			
		||||
 | 
			
		||||
            movementManager.fade(win, { to: 1 });
 | 
			
		||||
            movementManager.animateLayout(targetLayout);
 | 
			
		||||
 | 
			
		||||
        } else {
 | 
			
		||||
            if (!win || !win.isVisible()) return;
 | 
			
		||||
 | 
			
		||||
            const currentBounds = win.getBounds();
 | 
			
		||||
            const targetPos = { ...currentBounds };
 | 
			
		||||
            if (name === 'listen') targetPos.x -= ANIM_OFFSET_X;
 | 
			
		||||
            else if (name === 'ask') targetPos.y -= ANIM_OFFSET_Y;
 | 
			
		||||
 | 
			
		||||
            movementManager.fade(win, { to: 0, onComplete: () => win.hide() });
 | 
			
		||||
            movementManager.animateWindowPosition(win, targetPos);
 | 
			
		||||
            
 | 
			
		||||
            // 다른 창들도 새 레이아웃으로 애니메이션
 | 
			
		||||
            const otherWindowsLayout = { ...targetLayout };
 | 
			
		||||
            delete otherWindowsLayout[name];
 | 
			
		||||
            movementManager.animateLayout(otherWindowsLayout);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const setContentProtection = (status) => {
 | 
			
		||||
    isContentProtectionOn = status;
 | 
			
		||||
    console.log(`[Protection] Content protection toggled to: ${isContentProtectionOn}`);
 | 
			
		||||
@ -423,6 +72,108 @@ const toggleContentProtection = () => {
 | 
			
		||||
    return newStatus;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const resizeHeaderWindow = ({ width, height }) => {
 | 
			
		||||
    const header = windowPool.get('header');
 | 
			
		||||
    if (header) {
 | 
			
		||||
      console.log(`[WindowManager] Resize request: ${width}x${height}`);
 | 
			
		||||
      
 | 
			
		||||
      if (movementManager && movementManager.isAnimating) {
 | 
			
		||||
        console.log('[WindowManager] Skipping resize during animation');
 | 
			
		||||
        return { success: false, error: 'Cannot resize during animation' };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const currentBounds = header.getBounds();
 | 
			
		||||
      console.log(`[WindowManager] Current bounds: ${currentBounds.width}x${currentBounds.height} at (${currentBounds.x}, ${currentBounds.y})`);
 | 
			
		||||
      
 | 
			
		||||
      if (currentBounds.width === width && currentBounds.height === height) {
 | 
			
		||||
        console.log('[WindowManager] Already at target size, skipping resize');
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const wasResizable = header.isResizable();
 | 
			
		||||
      if (!wasResizable) {
 | 
			
		||||
        header.setResizable(true);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const centerX = currentBounds.x + currentBounds.width / 2;
 | 
			
		||||
      const newX = Math.round(centerX - width / 2);
 | 
			
		||||
 | 
			
		||||
      const display = getCurrentDisplay(header);
 | 
			
		||||
      const { x: workAreaX, width: workAreaWidth } = display.workArea;
 | 
			
		||||
      
 | 
			
		||||
      const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX));
 | 
			
		||||
 | 
			
		||||
      header.setBounds({ x: clampedX, y: currentBounds.y, width, height });
 | 
			
		||||
 | 
			
		||||
      if (!wasResizable) {
 | 
			
		||||
        header.setResizable(false);
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      if (updateLayout) {
 | 
			
		||||
        updateLayout();
 | 
			
		||||
      }
 | 
			
		||||
      
 | 
			
		||||
      return { success: true };
 | 
			
		||||
    }
 | 
			
		||||
    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';
 | 
			
		||||
@ -431,6 +182,12 @@ const openLoginPage = () => {
 | 
			
		||||
    console.log('Opening personalization page:', personalizeUrl);
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const moveWindowStep = (direction) => {
 | 
			
		||||
    if (movementManager) {
 | 
			
		||||
        movementManager.moveStep(direction);
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function createFeatureWindows(header, namesToCreate) {
 | 
			
		||||
    // if (windowPool.has('listen')) return;
 | 
			
		||||
@ -444,7 +201,7 @@ function createFeatureWindows(header, namesToCreate) {
 | 
			
		||||
        hasShadow: false,
 | 
			
		||||
        skipTaskbar: true,
 | 
			
		||||
        hiddenInMissionControl: true,
 | 
			
		||||
        resizable: false,
 | 
			
		||||
        resizable: true,
 | 
			
		||||
        webPreferences: {
 | 
			
		||||
            nodeIntegration: false,
 | 
			
		||||
            contextIsolation: true,
 | 
			
		||||
@ -559,7 +316,7 @@ function createFeatureWindows(header, namesToCreate) {
 | 
			
		||||
            case 'shortcut-settings': {
 | 
			
		||||
                const shortcutEditor = new BrowserWindow({
 | 
			
		||||
                    ...commonChildOptions,
 | 
			
		||||
                    width: 353,
 | 
			
		||||
                    width: 420,
 | 
			
		||||
                    height: 720,
 | 
			
		||||
                    modal: false,
 | 
			
		||||
                    parent: undefined,
 | 
			
		||||
@ -567,11 +324,36 @@ function createFeatureWindows(header, namesToCreate) {
 | 
			
		||||
                    titleBarOverlay: false,
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                shortcutEditor.setContentProtection(isContentProtectionOn);
 | 
			
		||||
                shortcutEditor.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
 | 
			
		||||
                if (process.platform === 'darwin') {
 | 
			
		||||
                    shortcutEditor.setWindowButtonVisibility(false);
 | 
			
		||||
                    shortcutEditor.setAlwaysOnTop(true, 'screen-saver');
 | 
			
		||||
                } else {
 | 
			
		||||
                    shortcutEditor.setAlwaysOnTop(true);
 | 
			
		||||
                }
 | 
			
		||||
            
 | 
			
		||||
                /* ──────────[ ① 다른 창 클릭 차단 ]────────── */
 | 
			
		||||
                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) {
 | 
			
		||||
@ -586,11 +368,23 @@ 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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
@ -604,7 +398,6 @@ function createFeatureWindows(header, namesToCreate) {
 | 
			
		||||
        createFeatureWindow('listen');
 | 
			
		||||
        createFeatureWindow('ask');
 | 
			
		||||
        createFeatureWindow('settings');
 | 
			
		||||
        createFeatureWindow('shortcut-settings');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -635,18 +428,52 @@ function getCurrentDisplay(window) {
 | 
			
		||||
    return screen.getDisplayNearestPoint(windowCenter);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getDisplayById(displayId) {
 | 
			
		||||
    const displays = screen.getAllDisplays();
 | 
			
		||||
    return displays.find(d => d.id === displayId) || screen.getPrimaryDisplay();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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() {
 | 
			
		||||
    const HEADER_HEIGHT        = 47;
 | 
			
		||||
    const DEFAULT_WINDOW_WIDTH = 353;
 | 
			
		||||
 | 
			
		||||
    const primaryDisplay = screen.getPrimaryDisplay();
 | 
			
		||||
    const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea;
 | 
			
		||||
 | 
			
		||||
    const initialX = Math.round((screenWidth - DEFAULT_WINDOW_WIDTH) / 2);
 | 
			
		||||
    const initialY = workAreaY + 21;
 | 
			
		||||
        
 | 
			
		||||
    movementManager = new SmoothMovementManager(windowPool, getDisplayById, getCurrentDisplay, updateLayout);
 | 
			
		||||
    
 | 
			
		||||
    const header = new BrowserWindow({
 | 
			
		||||
        width: DEFAULT_WINDOW_WIDTH,
 | 
			
		||||
        height: HEADER_HEIGHT,
 | 
			
		||||
@ -655,7 +482,6 @@ function createWindows() {
 | 
			
		||||
        frame: false,
 | 
			
		||||
        transparent: true,
 | 
			
		||||
        vibrancy: false,
 | 
			
		||||
        hasShadow: false,
 | 
			
		||||
        alwaysOnTop: true,
 | 
			
		||||
        skipTaskbar: true,
 | 
			
		||||
        hiddenInMissionControl: true,
 | 
			
		||||
@ -696,24 +522,15 @@ function createWindows() {
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    windowPool.set('header', header);
 | 
			
		||||
    header.on('moved', updateLayout);
 | 
			
		||||
    layoutManager = new WindowLayoutManager(windowPool);
 | 
			
		||||
    movementManager = new SmoothMovementManager(windowPool);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    header.on('moved', () => {
 | 
			
		||||
        if (movementManager.isAnimating) {
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        updateChildWindowLayouts(false);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    header.webContents.once('dom-ready', () => {
 | 
			
		||||
        shortcutsService.initialize(windowPool);
 | 
			
		||||
        shortcutsService.initialize(movementManager, windowPool);
 | 
			
		||||
        shortcutsService.registerShortcuts();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    setupIpcHandlers(windowPool, layoutManager);
 | 
			
		||||
    setupWindowController(windowPool, layoutManager, movementManager);
 | 
			
		||||
    setupIpcHandlers(movementManager);
 | 
			
		||||
 | 
			
		||||
    if (currentHeaderState === 'main') {
 | 
			
		||||
        createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']);
 | 
			
		||||
@ -744,13 +561,16 @@ function createWindows() {
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    header.on('resize', () => updateChildWindowLayouts(false));
 | 
			
		||||
    header.on('resize', () => {
 | 
			
		||||
        console.log('[WindowManager] Header resize event triggered');
 | 
			
		||||
        updateLayout();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    return windowPool;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function setupIpcHandlers(windowPool, layoutManager) {
 | 
			
		||||
function setupIpcHandlers(movementManager) {
 | 
			
		||||
    // quit-application handler moved to windowBridge.js to avoid duplication
 | 
			
		||||
    screen.on('display-added', (event, newDisplay) => {
 | 
			
		||||
        console.log('[Display] New display added:', newDisplay.id);
 | 
			
		||||
    });
 | 
			
		||||
@ -758,25 +578,18 @@ function setupIpcHandlers(windowPool, layoutManager) {
 | 
			
		||||
    screen.on('display-removed', (event, oldDisplay) => {
 | 
			
		||||
        console.log('[Display] Display removed:', oldDisplay.id);
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
 | 
			
		||||
        if (header && getCurrentDisplay(header).id === oldDisplay.id) {
 | 
			
		||||
            const primaryDisplay = screen.getPrimaryDisplay();
 | 
			
		||||
            const newPosition = layoutManager.calculateNewPositionForDisplay(header, primaryDisplay.id);
 | 
			
		||||
            if (newPosition) {
 | 
			
		||||
                // 복구 상황이므로 애니메이션 없이 즉시 이동
 | 
			
		||||
                header.setPosition(newPosition.x, newPosition.y, false);
 | 
			
		||||
                updateChildWindowLayouts(false);
 | 
			
		||||
            }
 | 
			
		||||
            movementManager.moveToDisplay(primaryDisplay.id);
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    screen.on('display-metrics-changed', (event, display, changedMetrics) => {
 | 
			
		||||
        // 레이아웃 업데이트 함수를 새 버전으로 호출
 | 
			
		||||
        updateChildWindowLayouts(false);
 | 
			
		||||
        // console.log('[Display] Display metrics changed:', display.id, changedMetrics);
 | 
			
		||||
        updateLayout();
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
const handleHeaderStateChanged = (state) => {
 | 
			
		||||
    console.log(`[WindowManager] Header state changed to: ${state}`);
 | 
			
		||||
    currentHeaderState = state;
 | 
			
		||||
@ -789,21 +602,163 @@ const handleHeaderStateChanged = (state) => {
 | 
			
		||||
    internalBridge.emit('reregister-shortcuts');
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleHeaderAnimationFinished = (state) => {
 | 
			
		||||
    const header = windowPool.get('header');
 | 
			
		||||
    if (!header || header.isDestroyed()) return;
 | 
			
		||||
 | 
			
		||||
    if (state === 'hidden') {
 | 
			
		||||
        header.hide();
 | 
			
		||||
        console.log('[WindowManager] Header hidden after animation.');
 | 
			
		||||
    } else if (state === 'visible') {
 | 
			
		||||
        console.log('[WindowManager] Header shown after animation.');
 | 
			
		||||
        updateLayout();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const getHeaderPosition = () => {
 | 
			
		||||
    const header = windowPool.get('header');
 | 
			
		||||
    if (header) {
 | 
			
		||||
        const [x, y] = header.getPosition();
 | 
			
		||||
        return { x, y };
 | 
			
		||||
    }
 | 
			
		||||
    return { x: 0, y: 0 };
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const moveHeader = (newX, newY) => {
 | 
			
		||||
    const header = windowPool.get('header');
 | 
			
		||||
    if (header) {
 | 
			
		||||
        const currentY = newY !== undefined ? newY : header.getBounds().y;
 | 
			
		||||
        header.setPosition(newX, currentY, false);
 | 
			
		||||
        updateLayout();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const moveHeaderTo = (newX, newY) => {
 | 
			
		||||
    const header = windowPool.get('header');
 | 
			
		||||
    if (header) {
 | 
			
		||||
        const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
 | 
			
		||||
        const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
 | 
			
		||||
        const headerBounds = header.getBounds();
 | 
			
		||||
 | 
			
		||||
        let clampedX = newX;
 | 
			
		||||
        let clampedY = newY;
 | 
			
		||||
        
 | 
			
		||||
        if (newX < workAreaX) {
 | 
			
		||||
            clampedX = workAreaX;
 | 
			
		||||
        } else if (newX + headerBounds.width > workAreaX + width) {
 | 
			
		||||
            clampedX = workAreaX + width - headerBounds.width;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (newY < workAreaY) {
 | 
			
		||||
            clampedY = workAreaY;
 | 
			
		||||
        } else if (newY + headerBounds.height > workAreaY + height) {
 | 
			
		||||
            clampedY = workAreaY + height - headerBounds.height;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        header.setPosition(clampedX, clampedY, false);
 | 
			
		||||
        updateLayout();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const adjustWindowHeight = (sender, targetHeight) => {
 | 
			
		||||
    const senderWindow = BrowserWindow.fromWebContents(sender);
 | 
			
		||||
    if (senderWindow) {
 | 
			
		||||
        const wasResizable = senderWindow.isResizable();
 | 
			
		||||
        if (!wasResizable) {
 | 
			
		||||
            senderWindow.setResizable(true);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const currentBounds = senderWindow.getBounds();
 | 
			
		||||
        const minHeight = senderWindow.getMinimumSize()[1];
 | 
			
		||||
        const maxHeight = senderWindow.getMaximumSize()[1];
 | 
			
		||||
        
 | 
			
		||||
        let adjustedHeight;
 | 
			
		||||
        if (maxHeight === 0) {
 | 
			
		||||
            adjustedHeight = Math.max(minHeight, targetHeight);
 | 
			
		||||
        } else {
 | 
			
		||||
            adjustedHeight = Math.max(minHeight, Math.min(maxHeight, targetHeight));
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        senderWindow.setSize(currentBounds.width, adjustedHeight, false);
 | 
			
		||||
 | 
			
		||||
        if (!wasResizable) {
 | 
			
		||||
            senderWindow.setResizable(false);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        updateLayout();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const handleAnimationFinished = (sender) => {
 | 
			
		||||
    const win = BrowserWindow.fromWebContents(sender);
 | 
			
		||||
    if (win && !win.isDestroyed()) {
 | 
			
		||||
        console.log(`[WindowManager] Hiding window after animation.`);
 | 
			
		||||
        win.hide();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const closeAskWindow = () => {
 | 
			
		||||
    const askWindow = windowPool.get('ask');
 | 
			
		||||
    if (askWindow) {
 | 
			
		||||
        askWindow.webContents.send('window-hide-animation');
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
async function ensureAskWindowVisible() {
 | 
			
		||||
    if (currentHeaderState !== 'main') {
 | 
			
		||||
        console.log('[WindowManager] Not in main state, skipping ensureAskWindowVisible');
 | 
			
		||||
        return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let askWindow = windowPool.get('ask');
 | 
			
		||||
 | 
			
		||||
    if (!askWindow || askWindow.isDestroyed()) {
 | 
			
		||||
        console.log('[WindowManager] Ask window not found, creating new one');
 | 
			
		||||
        createFeatureWindows(windowPool.get('header'), 'ask');
 | 
			
		||||
        askWindow = windowPool.get('ask');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    if (!askWindow.isVisible()) {
 | 
			
		||||
        console.log('[WindowManager] Showing hidden Ask window');
 | 
			
		||||
        askWindow.show();
 | 
			
		||||
        updateLayout();
 | 
			
		||||
        askWindow.webContents.send('window-show-animation');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
//////// after_modelStateService ////////
 | 
			
		||||
 | 
			
		||||
const closeWindow = (windowName) => {
 | 
			
		||||
    const win = windowPool.get(windowName);
 | 
			
		||||
    if (win && !win.isDestroyed()) {
 | 
			
		||||
        win.close();
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    updateLayout,
 | 
			
		||||
    createWindows,
 | 
			
		||||
    windowPool,
 | 
			
		||||
    fixedYPosition,
 | 
			
		||||
    toggleContentProtection,
 | 
			
		||||
    resizeHeaderWindow,
 | 
			
		||||
    getContentProtectionStatus,
 | 
			
		||||
    openShortcutEditor,
 | 
			
		||||
    showSettingsWindow,
 | 
			
		||||
    hideSettingsWindow,
 | 
			
		||||
    cancelHideSettingsWindow,
 | 
			
		||||
    openLoginPage,
 | 
			
		||||
    moveWindowStep,
 | 
			
		||||
    closeWindow,
 | 
			
		||||
    toggleAllWindowsVisibility,
 | 
			
		||||
    handleHeaderStateChanged,
 | 
			
		||||
    handleHeaderAnimationFinished,
 | 
			
		||||
    getHeaderPosition,
 | 
			
		||||
    moveHeader,
 | 
			
		||||
    moveHeaderTo,
 | 
			
		||||
    adjustWindowHeight,
 | 
			
		||||
    handleAnimationFinished,
 | 
			
		||||
    closeAskWindow,
 | 
			
		||||
    ensureAskWindowVisible,
 | 
			
		||||
};
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user