Compare commits
	
		
			44 Commits
		
	
	
		
			main
			...
			refactor/0
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					fa8d797be8 | ||
| 
						 | 
					f18fae6c90 | ||
| 
						 | 
					d62dad6992 | ||
| 
						 | 
					e043b85bcd | ||
| 
						 | 
					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 | 
							
								
								
									
										2
									
								
								aec
									
									
									
									
									
								
							
							
								
								
								
								
								
								
									
									
								
							
						
						@ -1 +1 @@
 | 
			
		||||
Subproject commit 3be088c6cff8021c74eca714150e68e2cc74bee0
 | 
			
		||||
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f
 | 
			
		||||
							
								
								
									
										4
									
								
								build.js
									
									
									
									
									
								
							
							
						
						@ -14,8 +14,8 @@ const baseConfig = {
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
const entryPoints = [
 | 
			
		||||
    { in: 'src/app/HeaderController.js', out: 'public/build/header' },
 | 
			
		||||
    { in: 'src/app/PickleGlassApp.js', out: 'public/build/content' },
 | 
			
		||||
    { in: 'src/ui/app/HeaderController.js', out: 'public/build/header' },
 | 
			
		||||
    { in: 'src/ui/app/PickleGlassApp.js', out: 'public/build/content' },
 | 
			
		||||
];
 | 
			
		||||
 | 
			
		||||
async function build() {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										19
									
								
								docs/refactor-plan.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,19 @@
 | 
			
		||||
# Refactor Plan: Non-Window Logic Migration from windowManager.js
 | 
			
		||||
 | 
			
		||||
## Goal
 | 
			
		||||
`windowManager.js`를 순수 창 관리 모듈로 만들기 위해 비즈니스 로직을 해당 서비스와 `featureBridge.js`로 이전.
 | 
			
		||||
 | 
			
		||||
## Steps (based on initial plan)
 | 
			
		||||
1. **Shortcuts**: Completed. Logic moved to `shortcutsService.js` and IPC to `featureBridge.js`. Used `internalBridge` for coordination.
 | 
			
		||||
 | 
			
		||||
2. **Screenshot**: Next. Move `captureScreenshot` function and related IPC handlers from `windowManager.js` to `askService.js` (since it's primarily used there). Update `askService.js` to use its own screenshot method. Add IPC handlers to `featureBridge.js` if needed.
 | 
			
		||||
 | 
			
		||||
3. **System Permissions**: Create new `permissionService.js` in `src/features/common/services/`. Move all permission-related logic (check, request, open preferences, mark completed, etc.) and IPC handlers from `windowManager.js` to the new service and `featureBridge.js`.
 | 
			
		||||
 | 
			
		||||
4. **API Key / Model State**: Completely remove from `windowManager.js` (e.g., `setupApiKeyIPC` and helpers). Ensure all usages (e.g., in `askService.js`) directly require and use `modelStateService.js` instead.
 | 
			
		||||
 | 
			
		||||
## Notes
 | 
			
		||||
- Maintain original logic without changes.
 | 
			
		||||
- Break circular dependencies if found.
 | 
			
		||||
- Use `internalBridge` for inter-module communication where appropriate.
 | 
			
		||||
- After each step, verify no errors and test functionality. 
 | 
			
		||||
@ -33,7 +33,7 @@ extraResources:
 | 
			
		||||
      to: out
 | 
			
		||||
 | 
			
		||||
asarUnpack:
 | 
			
		||||
    - "src/assets/SystemAudioDump"
 | 
			
		||||
    - "src/ui/assets/SystemAudioDump"
 | 
			
		||||
    - "**/node_modules/sharp/**/*"
 | 
			
		||||
    - "**/node_modules/@img/**/*"
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										119
									
								
								src/bridge/featureBridge.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,119 @@
 | 
			
		||||
// src/bridge/featureBridge.js
 | 
			
		||||
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 askService = require('../features/ask/askService');
 | 
			
		||||
const listenService = require('../features/listen/listenService');
 | 
			
		||||
const permissionService = require('../features/common/services/permissionService');
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  // 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));    
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('settings:get-ollama-status', async () => await settingsService.getOllamaStatus());
 | 
			
		||||
    ipcMain.handle('settings:ensure-ollama-ready', async () => await settingsService.ensureOllamaReady());
 | 
			
		||||
    ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama());
 | 
			
		||||
 | 
			
		||||
    // Shortcuts
 | 
			
		||||
    ipcMain.handle('get-current-shortcuts', async () => await shortcutsService.loadKeybinds());
 | 
			
		||||
    ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults());
 | 
			
		||||
    ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
 | 
			
		||||
 | 
			
		||||
    // 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-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
 | 
			
		||||
    ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
 | 
			
		||||
 | 
			
		||||
    // User/Auth
 | 
			
		||||
    ipcMain.handle('get-current-user', () => authService.getCurrentUser());
 | 
			
		||||
    ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow());
 | 
			
		||||
    ipcMain.handle('firebase-logout', async () => await authService.signOut());
 | 
			
		||||
 | 
			
		||||
    // App
 | 
			
		||||
    ipcMain.handle('quit-application', () => app.quit());
 | 
			
		||||
 | 
			
		||||
    // Whisper
 | 
			
		||||
    ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(modelId));
 | 
			
		||||
    ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
 | 
			
		||||
       
 | 
			
		||||
    // General
 | 
			
		||||
    ipcMain.handle('get-preset-templates', () => presetRepository.getPresetTemplates());
 | 
			
		||||
    ipcMain.handle('get-web-url', () => process.env.pickleglass_WEB_URL || 'http://localhost:3000');
 | 
			
		||||
 | 
			
		||||
    // 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: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: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));
 | 
			
		||||
 | 
			
		||||
    // 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());
 | 
			
		||||
 | 
			
		||||
    // Listen
 | 
			
		||||
    ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType));
 | 
			
		||||
    ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => {
 | 
			
		||||
        const result = await listenService.sttService.sendSystemAudioContent(data, mimeType);
 | 
			
		||||
        if(result.success) {
 | 
			
		||||
            listenService.sendToRenderer('system-audio-data', { data });
 | 
			
		||||
        }
 | 
			
		||||
        return result;
 | 
			
		||||
    });
 | 
			
		||||
    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:changeSession', async (event, listenButtonText) => {
 | 
			
		||||
      console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText);
 | 
			
		||||
      try {
 | 
			
		||||
        await listenService.handleListenRequest(listenButtonText);
 | 
			
		||||
        return { success: true };
 | 
			
		||||
      } catch (error) {
 | 
			
		||||
        console.error('[FeatureBridge] listen:changeSession failed', error.message);
 | 
			
		||||
        return { success: false, error: error.message };
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // ModelStateService
 | 
			
		||||
    ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
 | 
			
		||||
    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', () => modelStateService.getSelectedModels());
 | 
			
		||||
    ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId));
 | 
			
		||||
    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());
 | 
			
		||||
 | 
			
		||||
    console.log('[FeatureBridge] Initialized with all feature handlers.');
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // Renderer로 상태를 전송
 | 
			
		||||
  sendAskProgress(win, progress) {
 | 
			
		||||
    win.webContents.send('feature:ask:progress', progress);
 | 
			
		||||
  },
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										11
									
								
								src/bridge/internalBridge.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,11 @@
 | 
			
		||||
// src/bridge/internalBridge.js
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
 | 
			
		||||
// FeatureCore와 WindowCore를 잇는 내부 이벤트 버스
 | 
			
		||||
const internalBridge = new EventEmitter();
 | 
			
		||||
module.exports = internalBridge;
 | 
			
		||||
 | 
			
		||||
// 예시 이벤트
 | 
			
		||||
// internalBridge.on('content-protection-changed', (enabled) => {
 | 
			
		||||
//   // windowManager에서 처리
 | 
			
		||||
// });
 | 
			
		||||
							
								
								
									
										34
									
								
								src/bridge/windowBridge.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,34 @@
 | 
			
		||||
// src/bridge/windowBridge.js
 | 
			
		||||
const { ipcMain, BrowserWindow } = require('electron');
 | 
			
		||||
const windowManager = require('../window/windowManager');
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
  initialize() {
 | 
			
		||||
    ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection());
 | 
			
		||||
    ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args));
 | 
			
		||||
    ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus());
 | 
			
		||||
    ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor());
 | 
			
		||||
    ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds));
 | 
			
		||||
    ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
 | 
			
		||||
    ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
 | 
			
		||||
    ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
 | 
			
		||||
    ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
 | 
			
		||||
    ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
 | 
			
		||||
    ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));
 | 
			
		||||
 | 
			
		||||
    // 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, 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) {
 | 
			
		||||
    win.webContents.send('window:focus-change', isFocused);
 | 
			
		||||
  }
 | 
			
		||||
};
 | 
			
		||||
@ -1,260 +0,0 @@
 | 
			
		||||
const http = require('http');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
 | 
			
		||||
class OllamaProvider {
 | 
			
		||||
    static async validateApiKey() {
 | 
			
		||||
        try {
 | 
			
		||||
            const response = await fetch('http://localhost:11434/api/tags');
 | 
			
		||||
            if (response.ok) {
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } else {
 | 
			
		||||
                return { success: false, error: 'Ollama service is not running. Please start Ollama first.' };
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            return { success: false, error: 'Cannot connect to Ollama. Please ensure Ollama is installed and running.' };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function convertMessagesToOllamaFormat(messages) {
 | 
			
		||||
    return messages.map(msg => {
 | 
			
		||||
        if (Array.isArray(msg.content)) {
 | 
			
		||||
            let textContent = '';
 | 
			
		||||
            const images = [];
 | 
			
		||||
            
 | 
			
		||||
            for (const part of msg.content) {
 | 
			
		||||
                if (part.type === 'text') {
 | 
			
		||||
                    textContent += part.text;
 | 
			
		||||
                } else if (part.type === 'image_url') {
 | 
			
		||||
                    const base64 = part.image_url.url.replace(/^data:image\/[^;]+;base64,/, '');
 | 
			
		||||
                    images.push(base64);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return {
 | 
			
		||||
                role: msg.role,
 | 
			
		||||
                content: textContent,
 | 
			
		||||
                ...(images.length > 0 && { images })
 | 
			
		||||
            };
 | 
			
		||||
        } else {
 | 
			
		||||
            return msg;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createLLM({ 
 | 
			
		||||
    model, 
 | 
			
		||||
    temperature = 0.7, 
 | 
			
		||||
    maxTokens = 2048, 
 | 
			
		||||
    baseUrl = 'http://localhost:11434',
 | 
			
		||||
    ...config 
 | 
			
		||||
}) {
 | 
			
		||||
    if (!model) {
 | 
			
		||||
        throw new Error('Model parameter is required for Ollama LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
        generateContent: async (parts) => {
 | 
			
		||||
            let systemPrompt = '';
 | 
			
		||||
            const userContent = [];
 | 
			
		||||
 | 
			
		||||
            for (const part of parts) {
 | 
			
		||||
                if (typeof part === 'string') {
 | 
			
		||||
                    if (systemPrompt === '' && part.includes('You are')) {
 | 
			
		||||
                        systemPrompt = part;
 | 
			
		||||
                    } else {
 | 
			
		||||
                        userContent.push(part);
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (part.inlineData) {
 | 
			
		||||
                    userContent.push({
 | 
			
		||||
                        type: 'image',
 | 
			
		||||
                        image: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const messages = [];
 | 
			
		||||
            if (systemPrompt) {
 | 
			
		||||
                messages.push({ role: 'system', content: systemPrompt });
 | 
			
		||||
            }
 | 
			
		||||
            messages.push({ role: 'user', content: userContent.join('\n') });
 | 
			
		||||
 | 
			
		||||
            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;
 | 
			
		||||
            }
 | 
			
		||||
        },
 | 
			
		||||
 | 
			
		||||
        chat: async (messages) => {
 | 
			
		||||
            const ollamaMessages = convertMessagesToOllamaFormat(messages);
 | 
			
		||||
 | 
			
		||||
            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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createStreamingLLM({ 
 | 
			
		||||
    model, 
 | 
			
		||||
    temperature = 0.7, 
 | 
			
		||||
    maxTokens = 2048, 
 | 
			
		||||
    baseUrl = 'http://localhost:11434',
 | 
			
		||||
    ...config 
 | 
			
		||||
}) {
 | 
			
		||||
    if (!model) {
 | 
			
		||||
        throw new Error('Model parameter is required for Ollama streaming LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
        streamChat: async (messages) => {
 | 
			
		||||
            console.log('[Ollama Provider] Starting streaming request');
 | 
			
		||||
 | 
			
		||||
            const ollamaMessages = convertMessagesToOllamaFormat(messages);
 | 
			
		||||
            console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
 | 
			
		||||
 | 
			
		||||
            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);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                return {
 | 
			
		||||
                    ok: true,
 | 
			
		||||
                    body: stream
 | 
			
		||||
                };
 | 
			
		||||
                
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('[Ollama Provider] Request error:', error);
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    OllamaProvider,
 | 
			
		||||
    createLLM,
 | 
			
		||||
    createStreamingLLM,
 | 
			
		||||
    convertMessagesToOllamaFormat
 | 
			
		||||
}; 
 | 
			
		||||
@ -1,133 +0,0 @@
 | 
			
		||||
export class LocalProgressTracker {
 | 
			
		||||
    constructor(serviceName) {
 | 
			
		||||
        this.serviceName = serviceName;
 | 
			
		||||
        this.activeOperations = new Map(); // operationId -> { controller, onProgress }
 | 
			
		||||
        this.ipcRenderer = window.require?.('electron')?.ipcRenderer;
 | 
			
		||||
        
 | 
			
		||||
        if (!this.ipcRenderer) {
 | 
			
		||||
            throw new Error(`${serviceName} requires Electron environment`);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this.globalProgressHandler = (event, data) => {
 | 
			
		||||
            const operation = this.activeOperations.get(data.model || data.modelId);
 | 
			
		||||
            if (operation && !operation.controller.signal.aborted) {
 | 
			
		||||
                operation.onProgress(data.progress);
 | 
			
		||||
            }
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        const progressEvents = {
 | 
			
		||||
            'ollama': 'ollama:pull-progress',
 | 
			
		||||
            'whisper': 'whisper:download-progress'
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        const eventName = progressEvents[serviceName.toLowerCase()] || `${serviceName}:progress`;
 | 
			
		||||
        this.progressEvent = eventName;
 | 
			
		||||
        this.ipcRenderer.on(eventName, this.globalProgressHandler);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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 {
 | 
			
		||||
            const ipcChannels = {
 | 
			
		||||
                'ollama': { install: 'ollama:pull-model' },
 | 
			
		||||
                'whisper': { download: 'whisper:download-model' }
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            const channel = ipcChannels[this.serviceName.toLowerCase()]?.[operationType] || 
 | 
			
		||||
                           `${this.serviceName}:${operationType}`;
 | 
			
		||||
            
 | 
			
		||||
            const result = await this.ipcRenderer.invoke(channel, operationId);
 | 
			
		||||
            
 | 
			
		||||
            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();
 | 
			
		||||
        if (this.ipcRenderer) {
 | 
			
		||||
            this.ipcRenderer.removeListener(this.progressEvent, 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,150 +1,436 @@
 | 
			
		||||
const { ipcMain, BrowserWindow } = require('electron');
 | 
			
		||||
const { createStreamingLLM } = require('../../common/ai/factory');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../electron/windowManager');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { createStreamingLLM } = require('../common/ai/factory');
 | 
			
		||||
// Lazy require helper to avoid circular dependency issues
 | 
			
		||||
const getWindowManager = () => require('../../window/windowManager');
 | 
			
		||||
 | 
			
		||||
const getWindowPool = () => {
 | 
			
		||||
    try {
 | 
			
		||||
        return getWindowManager().windowPool;
 | 
			
		||||
    } catch {
 | 
			
		||||
        return null;
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
const updateLayout = () => getWindowManager().updateLayout();
 | 
			
		||||
const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible();
 | 
			
		||||
 | 
			
		||||
const sessionRepository = require('../common/repositories/session');
 | 
			
		||||
const askRepository = require('./repositories');
 | 
			
		||||
const { getSystemPrompt } = require('../../common/prompts/promptBuilder');
 | 
			
		||||
const { getSystemPrompt } = require('../common/prompts/promptBuilder');
 | 
			
		||||
const path = require('node:path');
 | 
			
		||||
const fs = require('node:fs');
 | 
			
		||||
const os = require('os');
 | 
			
		||||
const util = require('util');
 | 
			
		||||
const execFile = util.promisify(require('child_process').execFile);
 | 
			
		||||
const { desktopCapturer } = require('electron');
 | 
			
		||||
const modelStateService = require('../common/services/modelStateService');
 | 
			
		||||
 | 
			
		||||
function formatConversationForPrompt(conversationTexts) {
 | 
			
		||||
    if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.';
 | 
			
		||||
    return conversationTexts.slice(-30).join('\n');
 | 
			
		||||
// Try to load sharp, but don't fail if it's not available
 | 
			
		||||
let sharp;
 | 
			
		||||
try {
 | 
			
		||||
    sharp = require('sharp');
 | 
			
		||||
    console.log('[AskService] Sharp module loaded successfully');
 | 
			
		||||
} catch (error) {
 | 
			
		||||
    console.warn('[AskService] Sharp module not available:', error.message);
 | 
			
		||||
    console.warn('[AskService] Screenshot functionality will work with reduced image processing capabilities');
 | 
			
		||||
    sharp = null;
 | 
			
		||||
}
 | 
			
		||||
let lastScreenshot = null;
 | 
			
		||||
 | 
			
		||||
// Access conversation history via the global listenService instance created in index.js
 | 
			
		||||
function getConversationHistory() {
 | 
			
		||||
    const listenService = global.listenService;
 | 
			
		||||
    return listenService ? listenService.getConversationHistory() : [];
 | 
			
		||||
}
 | 
			
		||||
async function captureScreenshot(options = {}) {
 | 
			
		||||
    if (process.platform === 'darwin') {
 | 
			
		||||
        try {
 | 
			
		||||
            const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`);
 | 
			
		||||
 | 
			
		||||
async function sendMessage(userPrompt) {
 | 
			
		||||
    if (!userPrompt || userPrompt.trim().length === 0) {
 | 
			
		||||
        console.warn('[AskService] Cannot process empty message');
 | 
			
		||||
        return { success: false, error: 'Empty message' };
 | 
			
		||||
            await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]);
 | 
			
		||||
 | 
			
		||||
            const imageBuffer = await fs.promises.readFile(tempPath);
 | 
			
		||||
            await fs.promises.unlink(tempPath);
 | 
			
		||||
 | 
			
		||||
            if (sharp) {
 | 
			
		||||
                try {
 | 
			
		||||
                    // Try using sharp for optimal image processing
 | 
			
		||||
                    const resizedBuffer = await sharp(imageBuffer)
 | 
			
		||||
                        .resize({ height: 384 })
 | 
			
		||||
                        .jpeg({ quality: 80 })
 | 
			
		||||
                        .toBuffer();
 | 
			
		||||
 | 
			
		||||
                    const base64 = resizedBuffer.toString('base64');
 | 
			
		||||
                    const metadata = await sharp(resizedBuffer).metadata();
 | 
			
		||||
 | 
			
		||||
                    lastScreenshot = {
 | 
			
		||||
                        base64,
 | 
			
		||||
                        width: metadata.width,
 | 
			
		||||
                        height: metadata.height,
 | 
			
		||||
                        timestamp: Date.now(),
 | 
			
		||||
                    };
 | 
			
		||||
 | 
			
		||||
                    return { success: true, base64, width: metadata.width, height: metadata.height };
 | 
			
		||||
                } catch (sharpError) {
 | 
			
		||||
                    console.warn('Sharp module failed, falling back to basic image processing:', sharpError.message);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Fallback: Return the original image without resizing
 | 
			
		||||
            console.log('[AskService] Using fallback image processing (no resize/compression)');
 | 
			
		||||
            const base64 = imageBuffer.toString('base64');
 | 
			
		||||
            
 | 
			
		||||
            lastScreenshot = {
 | 
			
		||||
                base64,
 | 
			
		||||
                width: null, // We don't have metadata without sharp
 | 
			
		||||
                height: null,
 | 
			
		||||
                timestamp: Date.now(),
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            return { success: true, base64, width: null, height: null };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Failed to capture screenshot:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    const askWindow = windowPool.get('ask');
 | 
			
		||||
    if (askWindow && !askWindow.isDestroyed()) {
 | 
			
		||||
        askWindow.webContents.send('hide-text-input');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    let sessionId; 
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
 | 
			
		||||
 | 
			
		||||
        // --- Save user's message immediately ---
 | 
			
		||||
        // This ensures the user message is always timestamped before the assistant's response.
 | 
			
		||||
        sessionId = await sessionRepository.getOrCreateActive('ask');
 | 
			
		||||
        await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
 | 
			
		||||
        console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
 | 
			
		||||
        // --- End of user message saving ---
 | 
			
		||||
 | 
			
		||||
        const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
 | 
			
		||||
        if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
            throw new Error('AI model or API key not configured.');
 | 
			
		||||
        }
 | 
			
		||||
        console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
 | 
			
		||||
 | 
			
		||||
        const screenshotResult = await captureScreenshot({ quality: 'medium' });
 | 
			
		||||
        const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
 | 
			
		||||
 | 
			
		||||
        const conversationHistoryRaw = getConversationHistory();
 | 
			
		||||
        const conversationHistory = formatConversationForPrompt(conversationHistoryRaw);
 | 
			
		||||
 | 
			
		||||
        const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
        const messages = [
 | 
			
		||||
            { role: 'system', content: systemPrompt },
 | 
			
		||||
            {
 | 
			
		||||
                role: 'user',
 | 
			
		||||
                content: [
 | 
			
		||||
                    { type: 'text', text: `User Request: ${userPrompt.trim()}` },
 | 
			
		||||
                ],
 | 
			
		||||
        const sources = await desktopCapturer.getSources({
 | 
			
		||||
            types: ['screen'],
 | 
			
		||||
            thumbnailSize: {
 | 
			
		||||
                width: 1920,
 | 
			
		||||
                height: 1080,
 | 
			
		||||
            },
 | 
			
		||||
        ];
 | 
			
		||||
 | 
			
		||||
        if (screenshotBase64) {
 | 
			
		||||
            messages[1].content.push({
 | 
			
		||||
                type: 'image_url',
 | 
			
		||||
                image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const streamingLLM = createStreamingLLM(modelInfo.provider, {
 | 
			
		||||
            apiKey: modelInfo.apiKey,
 | 
			
		||||
            model: modelInfo.model,
 | 
			
		||||
            temperature: 0.7,
 | 
			
		||||
            maxTokens: 2048,
 | 
			
		||||
            usePortkey: modelInfo.provider === 'openai-glass',
 | 
			
		||||
            portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const response = await streamingLLM.streamChat(messages);
 | 
			
		||||
        if (sources.length === 0) {
 | 
			
		||||
            throw new Error('No screen sources available');
 | 
			
		||||
        }
 | 
			
		||||
        const source = sources[0];
 | 
			
		||||
        const buffer = source.thumbnail.toJPEG(70);
 | 
			
		||||
        const base64 = buffer.toString('base64');
 | 
			
		||||
        const size = source.thumbnail.getSize();
 | 
			
		||||
 | 
			
		||||
        // --- Stream Processing ---
 | 
			
		||||
        const reader = response.body.getReader();
 | 
			
		||||
        return {
 | 
			
		||||
            success: true,
 | 
			
		||||
            base64,
 | 
			
		||||
            width: size.width,
 | 
			
		||||
            height: size.height,
 | 
			
		||||
        };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('Failed to capture screenshot using desktopCapturer:', error);
 | 
			
		||||
        return {
 | 
			
		||||
            success: false,
 | 
			
		||||
            error: error.message,
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/**
 | 
			
		||||
 * @class
 | 
			
		||||
 * @description
 | 
			
		||||
 */
 | 
			
		||||
class AskService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.abortController = null;
 | 
			
		||||
        this.state = {
 | 
			
		||||
            isVisible: false,
 | 
			
		||||
            isLoading: false,
 | 
			
		||||
            isStreaming: false,
 | 
			
		||||
            currentQuestion: '',
 | 
			
		||||
            currentResponse: '',
 | 
			
		||||
            showTextInput: true,
 | 
			
		||||
        };
 | 
			
		||||
        console.log('[AskService] Service instance created.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    _broadcastState() {
 | 
			
		||||
        const askWindow = getWindowPool()?.get('ask');
 | 
			
		||||
        if (askWindow && !askWindow.isDestroyed()) {
 | 
			
		||||
            askWindow.webContents.send('ask:stateUpdate', this.state);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async toggleAskButton() {
 | 
			
		||||
        const askWindow = getWindowPool()?.get('ask');
 | 
			
		||||
 | 
			
		||||
        // 답변이 있거나 스트리밍 중일 때
 | 
			
		||||
        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(); // 변경된 상태 전파
 | 
			
		||||
        } else {
 | 
			
		||||
            // 기존의 창 보이기/숨기기 로직
 | 
			
		||||
            if (askWindow && askWindow.isVisible()) {
 | 
			
		||||
                askWindow.webContents.send('window-hide-animation');
 | 
			
		||||
                this.state.isVisible = false;
 | 
			
		||||
            } else {
 | 
			
		||||
                console.log('[AskService] Showing hidden Ask window');
 | 
			
		||||
                this.state.isVisible = true;
 | 
			
		||||
                askWindow?.show();
 | 
			
		||||
                updateLayout();
 | 
			
		||||
                askWindow?.webContents.send('window-show-animation');
 | 
			
		||||
            }
 | 
			
		||||
            // 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다.
 | 
			
		||||
            if (this.state.isVisible) {
 | 
			
		||||
                this.state.showTextInput = true;
 | 
			
		||||
                this._broadcastState();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {string[]} conversationTexts
 | 
			
		||||
     * @returns {string}
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    _formatConversationForPrompt(conversationTexts) {
 | 
			
		||||
        if (!conversationTexts || conversationTexts.length === 0) {
 | 
			
		||||
            return 'No conversation history available.';
 | 
			
		||||
        }
 | 
			
		||||
        return conversationTexts.slice(-30).join('\n');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {string} userPrompt
 | 
			
		||||
     * @returns {Promise<{success: boolean, response?: string, error?: string}>}
 | 
			
		||||
     */
 | 
			
		||||
    async sendMessage(userPrompt, conversationHistoryRaw=[]) {
 | 
			
		||||
        ensureAskWindowVisible();
 | 
			
		||||
 | 
			
		||||
        if (this.abortController) {
 | 
			
		||||
            this.abortController.abort('New request received.');
 | 
			
		||||
        }
 | 
			
		||||
        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 = modelStateService.getCurrentModelInfo('llm');
 | 
			
		||||
            if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
                throw new Error('AI model or API key not configured.');
 | 
			
		||||
            }
 | 
			
		||||
            console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
 | 
			
		||||
 | 
			
		||||
            const screenshotResult = await captureScreenshot({ quality: 'medium' });
 | 
			
		||||
            const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
 | 
			
		||||
 | 
			
		||||
            const conversationHistory = this._formatConversationForPrompt(conversationHistoryRaw);
 | 
			
		||||
            const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
 | 
			
		||||
 | 
			
		||||
            // 첫 번째 시도: 스크린샷 포함 (가능한 경우)
 | 
			
		||||
            const messages = [
 | 
			
		||||
                { role: 'system', content: systemPrompt },
 | 
			
		||||
                {
 | 
			
		||||
                    role: 'user',
 | 
			
		||||
                    content: [
 | 
			
		||||
                        { type: 'text', text: `User Request: ${userPrompt.trim()}` },
 | 
			
		||||
                    ],
 | 
			
		||||
                },
 | 
			
		||||
            ];
 | 
			
		||||
 | 
			
		||||
            if (screenshotBase64) {
 | 
			
		||||
                messages[1].content.push({
 | 
			
		||||
                    type: 'image_url',
 | 
			
		||||
                    image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            const streamingLLM = createStreamingLLM(modelInfo.provider, {
 | 
			
		||||
                apiKey: modelInfo.apiKey,
 | 
			
		||||
                model: modelInfo.model,
 | 
			
		||||
                temperature: 0.7,
 | 
			
		||||
                maxTokens: 2048,
 | 
			
		||||
                usePortkey: modelInfo.provider === 'openai-glass',
 | 
			
		||||
                portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                const response = await streamingLLM.streamChat(messages);
 | 
			
		||||
                const askWin = getWindowPool()?.get('ask');
 | 
			
		||||
 | 
			
		||||
                if (!askWin || askWin.isDestroyed()) {
 | 
			
		||||
                    console.error("[AskService] Ask window is not available to send stream to.");
 | 
			
		||||
                    response.body.getReader().cancel();
 | 
			
		||||
                    return { success: false, error: 'Ask window is not available.' };
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                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;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
        } 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 });
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {ReadableStreamDefaultReader} reader
 | 
			
		||||
     * @param {BrowserWindow} askWin
 | 
			
		||||
     * @param {number} sessionId 
 | 
			
		||||
     * @param {AbortSignal} signal
 | 
			
		||||
     * @returns {Promise<void>}
 | 
			
		||||
     * @private
 | 
			
		||||
     */
 | 
			
		||||
    async _processStream(reader, askWin, sessionId, signal) {
 | 
			
		||||
        const decoder = new TextDecoder();
 | 
			
		||||
        let fullResponse = '';
 | 
			
		||||
 | 
			
		||||
        const askWin = windowPool.get('ask');
 | 
			
		||||
        if (!askWin || askWin.isDestroyed()) {
 | 
			
		||||
            console.error("[AskService] Ask window is not available to send stream to.");
 | 
			
		||||
            reader.cancel();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        try {
 | 
			
		||||
            this.state.isLoading = false;
 | 
			
		||||
            this.state.isStreaming = true;
 | 
			
		||||
            this._broadcastState();
 | 
			
		||||
            while (true) {
 | 
			
		||||
                const { done, value } = await reader.read();
 | 
			
		||||
                if (done) break;
 | 
			
		||||
 | 
			
		||||
        while (true) {
 | 
			
		||||
            const { done, value } = await reader.read();
 | 
			
		||||
            if (done) break;
 | 
			
		||||
                const chunk = decoder.decode(value);
 | 
			
		||||
                const lines = chunk.split('\n').filter(line => line.trim() !== '');
 | 
			
		||||
 | 
			
		||||
            const chunk = decoder.decode(value);
 | 
			
		||||
            const lines = chunk.split('\n').filter(line => line.trim() !== '');
 | 
			
		||||
 | 
			
		||||
            for (const line of lines) {
 | 
			
		||||
                if (line.startsWith('data: ')) {
 | 
			
		||||
                    const data = line.substring(6);
 | 
			
		||||
                    if (data === '[DONE]') {
 | 
			
		||||
                        askWin.webContents.send('ask-response-stream-end');
 | 
			
		||||
                        
 | 
			
		||||
                        // Save assistant's message to DB
 | 
			
		||||
                for (const line of lines) {
 | 
			
		||||
                    if (line.startsWith('data: ')) {
 | 
			
		||||
                        const data = line.substring(6);
 | 
			
		||||
                        if (data === '[DONE]') {
 | 
			
		||||
                            return; 
 | 
			
		||||
                        }
 | 
			
		||||
                        try {
 | 
			
		||||
                            // sessionId is already available from when we saved the user prompt
 | 
			
		||||
                            await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
 | 
			
		||||
                            console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`);
 | 
			
		||||
                        } catch(dbError) {
 | 
			
		||||
                            console.error("[AskService] DB: Failed to save assistant response:", dbError);
 | 
			
		||||
                            const json = JSON.parse(data);
 | 
			
		||||
                            const token = json.choices[0]?.delta?.content || '';
 | 
			
		||||
                            if (token) {
 | 
			
		||||
                                fullResponse += token;
 | 
			
		||||
                                this.state.currentResponse = fullResponse;
 | 
			
		||||
                                this._broadcastState();
 | 
			
		||||
                            }
 | 
			
		||||
                        } catch (error) {
 | 
			
		||||
                            console.error('[AskService] Failed to parse stream data:', { line: data, error: error.message });
 | 
			
		||||
                        }
 | 
			
		||||
                        
 | 
			
		||||
                        return { success: true, response: fullResponse };
 | 
			
		||||
                    }
 | 
			
		||||
                    try {
 | 
			
		||||
                        const json = JSON.parse(data);
 | 
			
		||||
                        const token = json.choices[0]?.delta?.content || '';
 | 
			
		||||
                        if (token) {
 | 
			
		||||
                            fullResponse += token;
 | 
			
		||||
                            askWin.webContents.send('ask-response-chunk', { token });
 | 
			
		||||
                        }
 | 
			
		||||
                    } catch (error) {
 | 
			
		||||
                        // Ignore parsing errors for now
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } catch (streamError) {
 | 
			
		||||
            if (signal.aborted) {
 | 
			
		||||
                console.log(`[AskService] Stream reading was intentionally cancelled. Reason: ${signal.reason}`);
 | 
			
		||||
            } else {
 | 
			
		||||
                console.error('[AskService] Error while processing stream:', streamError);
 | 
			
		||||
                if (askWin && !askWin.isDestroyed()) {
 | 
			
		||||
                    askWin.webContents.send('ask-response-stream-error', { error: streamError.message });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        } finally {
 | 
			
		||||
            this.state.isStreaming = false;
 | 
			
		||||
            this.state.currentResponse = fullResponse;
 | 
			
		||||
            this._broadcastState();
 | 
			
		||||
            if (fullResponse) {
 | 
			
		||||
                 try {
 | 
			
		||||
                    await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
 | 
			
		||||
                    console.log(`[AskService] DB: Saved partial or full assistant response to session ${sessionId} after stream ended.`);
 | 
			
		||||
                } catch(dbError) {
 | 
			
		||||
                    console.error("[AskService] DB: Failed to save assistant response after stream ended:", dbError);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[AskService] Error processing message:', error);
 | 
			
		||||
        return { success: false, error: error.message };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 멀티모달 관련 에러인지 판단
 | 
			
		||||
     * @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')
 | 
			
		||||
        );
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function initialize() {
 | 
			
		||||
    ipcMain.handle('ask:sendMessage', async (event, userPrompt) => {
 | 
			
		||||
        return sendMessage(userPrompt);
 | 
			
		||||
    });
 | 
			
		||||
    console.log('[AskService] Initialized and ready.');
 | 
			
		||||
}
 | 
			
		||||
const askService = new AskService();
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    initialize,
 | 
			
		||||
};
 | 
			
		||||
module.exports = askService;
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
 | 
			
		||||
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
 | 
			
		||||
const { getFirestoreInstance } = require('../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter');
 | 
			
		||||
 | 
			
		||||
const aiMessageConverter = createEncryptedConverter(['content']);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const sqliteRepository = require('./sqlite.repository');
 | 
			
		||||
const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
const sqliteClient = require('../../../common/services/sqliteClient');
 | 
			
		||||
const sqliteClient = require('../../common/services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) {
 | 
			
		||||
    // uid is ignored in the SQLite implementation
 | 
			
		||||
 | 
			
		||||
@ -68,7 +68,8 @@ const PROVIDERS = {
 | 
			
		||||
      handler: () => {
 | 
			
		||||
          // This needs to remain a function due to its conditional logic for renderer/main process
 | 
			
		||||
          if (typeof window === 'undefined') {
 | 
			
		||||
              return require("./providers/whisper");
 | 
			
		||||
              const { WhisperProvider } = require("./providers/whisper");
 | 
			
		||||
              return new WhisperProvider();
 | 
			
		||||
          }
 | 
			
		||||
          // Return a dummy object for the renderer process
 | 
			
		||||
          return {
 | 
			
		||||
							
								
								
									
										342
									
								
								src/features/common/ai/providers/ollama.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,342 @@
 | 
			
		||||
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 {
 | 
			
		||||
            const response = await fetch('http://localhost:11434/api/tags');
 | 
			
		||||
            if (response.ok) {
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } else {
 | 
			
		||||
                return { success: false, error: 'Ollama service is not running. Please start Ollama first.' };
 | 
			
		||||
            }
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            return { success: false, error: 'Cannot connect to Ollama. Please ensure Ollama is installed and running.' };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
function convertMessagesToOllamaFormat(messages) {
 | 
			
		||||
    return messages.map(msg => {
 | 
			
		||||
        if (Array.isArray(msg.content)) {
 | 
			
		||||
            let textContent = '';
 | 
			
		||||
            const images = [];
 | 
			
		||||
            
 | 
			
		||||
            for (const part of msg.content) {
 | 
			
		||||
                if (part.type === 'text') {
 | 
			
		||||
                    textContent += part.text;
 | 
			
		||||
                } else if (part.type === 'image_url') {
 | 
			
		||||
                    const base64 = part.image_url.url.replace(/^data:image\/[^;]+;base64,/, '');
 | 
			
		||||
                    images.push(base64);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return {
 | 
			
		||||
                role: msg.role,
 | 
			
		||||
                content: textContent,
 | 
			
		||||
                ...(images.length > 0 && { images })
 | 
			
		||||
            };
 | 
			
		||||
        } else {
 | 
			
		||||
            return msg;
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createLLM({ 
 | 
			
		||||
    model, 
 | 
			
		||||
    temperature = 0.7, 
 | 
			
		||||
    maxTokens = 2048, 
 | 
			
		||||
    baseUrl = 'http://localhost:11434',
 | 
			
		||||
    ...config 
 | 
			
		||||
}) {
 | 
			
		||||
    if (!model) {
 | 
			
		||||
        throw new Error('Model parameter is required for Ollama LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
        generateContent: async (parts) => {
 | 
			
		||||
            let systemPrompt = '';
 | 
			
		||||
            const userContent = [];
 | 
			
		||||
 | 
			
		||||
            for (const part of parts) {
 | 
			
		||||
                if (typeof part === 'string') {
 | 
			
		||||
                    if (systemPrompt === '' && part.includes('You are')) {
 | 
			
		||||
                        systemPrompt = part;
 | 
			
		||||
                    } else {
 | 
			
		||||
                        userContent.push(part);
 | 
			
		||||
                    }
 | 
			
		||||
                } else if (part.inlineData) {
 | 
			
		||||
                    userContent.push({
 | 
			
		||||
                        type: 'image',
 | 
			
		||||
                        image: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const messages = [];
 | 
			
		||||
            if (systemPrompt) {
 | 
			
		||||
                messages.push({ role: 'system', content: systemPrompt });
 | 
			
		||||
            }
 | 
			
		||||
            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,
 | 
			
		||||
                            }
 | 
			
		||||
                        })
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    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,
 | 
			
		||||
                            }
 | 
			
		||||
                        })
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    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;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function createStreamingLLM({ 
 | 
			
		||||
    model, 
 | 
			
		||||
    temperature = 0.7, 
 | 
			
		||||
    maxTokens = 2048, 
 | 
			
		||||
    baseUrl = 'http://localhost:11434',
 | 
			
		||||
    ...config 
 | 
			
		||||
}) {
 | 
			
		||||
    if (!model) {
 | 
			
		||||
        throw new Error('Model parameter is required for Ollama streaming LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
 | 
			
		||||
    }
 | 
			
		||||
    return {
 | 
			
		||||
        streamChat: async (messages) => {
 | 
			
		||||
            console.log('[Ollama Provider] Starting streaming request');
 | 
			
		||||
 | 
			
		||||
            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);
 | 
			
		||||
                            }
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
 | 
			
		||||
                    return {
 | 
			
		||||
                        ok: true,
 | 
			
		||||
                        body: stream
 | 
			
		||||
                    };
 | 
			
		||||
                    
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error('[Ollama Provider] Request error:', error);
 | 
			
		||||
                    throw error;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    OllamaProvider,
 | 
			
		||||
    createLLM,
 | 
			
		||||
    createStreamingLLM,
 | 
			
		||||
    convertMessagesToOllamaFormat
 | 
			
		||||
}; 
 | 
			
		||||
@ -82,7 +82,7 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
 | 
			
		||||
            silence_duration_ms: 100,
 | 
			
		||||
          },
 | 
			
		||||
          input_audio_noise_reduction: {
 | 
			
		||||
            type: 'far_field'
 | 
			
		||||
            type: 'near_field'
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      };
 | 
			
		||||
@ -184,9 +184,10 @@ class WhisperProvider {
 | 
			
		||||
 | 
			
		||||
    async initialize() {
 | 
			
		||||
        if (!this.whisperService) {
 | 
			
		||||
            const { WhisperService } = require('../../services/whisperService');
 | 
			
		||||
            this.whisperService = new WhisperService();
 | 
			
		||||
            await this.whisperService.initialize();
 | 
			
		||||
            this.whisperService = require('../../services/whisperService');
 | 
			
		||||
            if (!this.whisperService.isInitialized) {
 | 
			
		||||
                await this.whisperService.initialize();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -110,6 +110,13 @@ const LATEST_SCHEMA = {
 | 
			
		||||
            { name: 'selected_stt_model', type: 'TEXT' },
 | 
			
		||||
            { name: 'updated_at', type: 'INTEGER' }
 | 
			
		||||
        ]
 | 
			
		||||
    },
 | 
			
		||||
    shortcuts: {
 | 
			
		||||
        columns: [
 | 
			
		||||
            { name: 'action', type: 'TEXT PRIMARY KEY' },
 | 
			
		||||
            { name: 'accelerator', type: 'TEXT NOT NULL' },
 | 
			
		||||
            { name: 'created_at', type: 'INTEGER' }
 | 
			
		||||
        ]
 | 
			
		||||
    }
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const sqliteRepository = require('./sqlite.repository');
 | 
			
		||||
const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
const authService = require('../../services/authService');
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
@ -1,5 +1,5 @@
 | 
			
		||||
const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { BrowserWindow, shell } = require('electron');
 | 
			
		||||
const { getFirebaseAuth } = require('./firebaseClient');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
const encryptionService = require('./encryptionService');
 | 
			
		||||
@ -131,6 +131,19 @@ class AuthService {
 | 
			
		||||
        return this.initializationPromise;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async startFirebaseAuthFlow() {
 | 
			
		||||
        try {
 | 
			
		||||
            const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
 | 
			
		||||
            const authUrl = `${webUrl}/login?mode=electron`;
 | 
			
		||||
            console.log(`[AuthService] Opening Firebase auth URL in browser: ${authUrl}`);
 | 
			
		||||
            await shell.openExternal(authUrl);
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[AuthService] Failed to open Firebase auth URL:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async signInWithCustomToken(token) {
 | 
			
		||||
        const auth = getFirebaseAuth();
 | 
			
		||||
        try {
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
const { exec } = require('child_process');
 | 
			
		||||
const { promisify } = require('util');
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const path = require('path');
 | 
			
		||||
const os = require('os');
 | 
			
		||||
const https = require('https');
 | 
			
		||||
@ -17,6 +18,19 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
        this.installationProgress = new Map();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 모든 윈도우에 이벤트 브로드캐스트
 | 
			
		||||
    _broadcastToAllWindows(eventName, data = null) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (win && !win.isDestroyed()) {
 | 
			
		||||
                if (data !== null) {
 | 
			
		||||
                    win.webContents.send(eventName, data);
 | 
			
		||||
                } else {
 | 
			
		||||
                    win.webContents.send(eventName);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getPlatform() {
 | 
			
		||||
        return process.platform;
 | 
			
		||||
    }
 | 
			
		||||
@ -65,7 +79,7 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
    setInstallProgress(modelName, progress) {
 | 
			
		||||
        this.installationProgress.set(modelName, progress);
 | 
			
		||||
        this.emit('install-progress', { model: modelName, progress });
 | 
			
		||||
        // 각 서비스에서 직접 브로드캐스트하도록 변경
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    clearInstallProgress(modelName) {
 | 
			
		||||
@ -152,7 +166,8 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
        const { 
 | 
			
		||||
            onProgress = null,
 | 
			
		||||
            headers = { 'User-Agent': 'Glass-App' },
 | 
			
		||||
            timeout = 300000 // 5 minutes default
 | 
			
		||||
            timeout = 300000, // 5 minutes default
 | 
			
		||||
            modelId = null // 모델 ID를 위한 추가 옵션
 | 
			
		||||
        } = options;
 | 
			
		||||
 | 
			
		||||
        return new Promise((resolve, reject) => {
 | 
			
		||||
@ -190,9 +205,15 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
                response.on('data', (chunk) => {
 | 
			
		||||
                    downloadedSize += chunk.length;
 | 
			
		||||
                    
 | 
			
		||||
                    if (onProgress && totalSize > 0) {
 | 
			
		||||
                    if (totalSize > 0) {
 | 
			
		||||
                        const progress = Math.round((downloadedSize / totalSize) * 100);
 | 
			
		||||
                        onProgress(progress, downloadedSize, totalSize);
 | 
			
		||||
                        
 | 
			
		||||
                        // 이벤트 기반 진행률 보고는 각 서비스에서 직접 처리
 | 
			
		||||
                        
 | 
			
		||||
                        // 기존 콜백 지원 (호환성 유지)
 | 
			
		||||
                        if (onProgress) {
 | 
			
		||||
                            onProgress(progress, downloadedSize, totalSize);
 | 
			
		||||
                        }
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
@ -200,7 +221,7 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
 | 
			
		||||
                file.on('finish', () => {
 | 
			
		||||
                    file.close(() => {
 | 
			
		||||
                        this.emit('download-complete', { url, destination, size: downloadedSize });
 | 
			
		||||
                        // download-complete 이벤트는 각 서비스에서 직접 처리
 | 
			
		||||
                        resolve({ success: true, size: downloadedSize });
 | 
			
		||||
                    });
 | 
			
		||||
                });
 | 
			
		||||
@ -216,7 +237,7 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
            request.on('error', (err) => {
 | 
			
		||||
                file.close();
 | 
			
		||||
                fs.unlink(destination, () => {});
 | 
			
		||||
                this.emit('download-error', { url, error: err });
 | 
			
		||||
                this.emit('download-error', { url, error: err, modelId });
 | 
			
		||||
                reject(err);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
@ -230,11 +251,20 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async downloadWithRetry(url, destination, options = {}) {
 | 
			
		||||
        const { maxRetries = 3, retryDelay = 1000, expectedChecksum = null, ...downloadOptions } = options;
 | 
			
		||||
        const { 
 | 
			
		||||
            maxRetries = 3, 
 | 
			
		||||
            retryDelay = 1000, 
 | 
			
		||||
            expectedChecksum = null,
 | 
			
		||||
            modelId = null, // 모델 ID를 위한 추가 옵션
 | 
			
		||||
            ...downloadOptions 
 | 
			
		||||
        } = options;
 | 
			
		||||
        
 | 
			
		||||
        for (let attempt = 1; attempt <= maxRetries; attempt++) {
 | 
			
		||||
            try {
 | 
			
		||||
                const result = await this.downloadFile(url, destination, downloadOptions);
 | 
			
		||||
                const result = await this.downloadFile(url, destination, { 
 | 
			
		||||
                    ...downloadOptions, 
 | 
			
		||||
                    modelId 
 | 
			
		||||
                });
 | 
			
		||||
                
 | 
			
		||||
                if (expectedChecksum) {
 | 
			
		||||
                    const isValid = await this.verifyChecksum(destination, expectedChecksum);
 | 
			
		||||
@ -248,6 +278,7 @@ class LocalAIServiceBase extends EventEmitter {
 | 
			
		||||
                return result;
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                if (attempt === maxRetries) {
 | 
			
		||||
                    // download-error 이벤트는 각 서비스에서 직접 처리
 | 
			
		||||
                    throw error;
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
@ -5,9 +5,9 @@ const encryptionService = require('../services/encryptionService');
 | 
			
		||||
const sqliteSessionRepo = require('../repositories/session/sqlite.repository');
 | 
			
		||||
const sqlitePresetRepo = require('../repositories/preset/sqlite.repository');
 | 
			
		||||
const sqliteUserRepo = require('../repositories/user/sqlite.repository');
 | 
			
		||||
const sqliteSttRepo = require('../../features/listen/stt/repositories/sqlite.repository');
 | 
			
		||||
const sqliteSummaryRepo = require('../../features/listen/summary/repositories/sqlite.repository');
 | 
			
		||||
const sqliteAiMessageRepo = require('../../features/ask/repositories/sqlite.repository');
 | 
			
		||||
const sqliteSttRepo = require('../../listen/stt/repositories/sqlite.repository');
 | 
			
		||||
const sqliteSummaryRepo = require('../../listen/summary/repositories/sqlite.repository');
 | 
			
		||||
const sqliteAiMessageRepo = require('../../ask/repositories/sqlite.repository');
 | 
			
		||||
 | 
			
		||||
const MAX_BATCH_OPERATIONS = 500;
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,18 @@
 | 
			
		||||
const Store = require('electron-store');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
const { ipcMain, webContents } = require('electron');
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { PROVIDERS, getProviderClass } = require('../ai/factory');
 | 
			
		||||
const encryptionService = require('./encryptionService');
 | 
			
		||||
const providerSettingsRepository = require('../repositories/providerSettings');
 | 
			
		||||
const userModelSelectionsRepository = require('../repositories/userModelSelections');
 | 
			
		||||
 | 
			
		||||
class ModelStateService {
 | 
			
		||||
    constructor(authService) {
 | 
			
		||||
// Import authService directly (singleton)
 | 
			
		||||
const authService = require('./authService');
 | 
			
		||||
 | 
			
		||||
class ModelStateService extends EventEmitter {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        super();
 | 
			
		||||
        this.authService = authService;
 | 
			
		||||
        this.store = new Store({ name: 'pickle-glass-model-state' });
 | 
			
		||||
        this.state = {};
 | 
			
		||||
@ -18,10 +23,22 @@ class ModelStateService {
 | 
			
		||||
        userModelSelectionsRepository.setAuthService(authService);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 모든 윈도우에 이벤트 브로드캐스트
 | 
			
		||||
    _broadcastToAllWindows(eventName, data = null) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (win && !win.isDestroyed()) {
 | 
			
		||||
                if (data !== null) {
 | 
			
		||||
                    win.webContents.send(eventName, data);
 | 
			
		||||
                } else {
 | 
			
		||||
                    win.webContents.send(eventName);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initialize() {
 | 
			
		||||
        console.log('[ModelStateService] Initializing...');
 | 
			
		||||
        await this._loadStateForCurrentUser();
 | 
			
		||||
        this.setupIpcHandlers();
 | 
			
		||||
        console.log('[ModelStateService] Initialization complete');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -34,15 +51,17 @@ class ModelStateService {
 | 
			
		||||
        console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _autoSelectAvailableModels() {
 | 
			
		||||
        console.log('[ModelStateService] Running auto-selection for models...');
 | 
			
		||||
    _autoSelectAvailableModels(forceReselectionForTypes = []) {
 | 
			
		||||
        console.log(`[ModelStateService] Running auto-selection for models. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
 | 
			
		||||
        const types = ['llm', 'stt'];
 | 
			
		||||
 | 
			
		||||
        types.forEach(type => {
 | 
			
		||||
            const currentModelId = this.state.selectedModels[type];
 | 
			
		||||
            let isCurrentModelValid = false;
 | 
			
		||||
 | 
			
		||||
            if (currentModelId) {
 | 
			
		||||
            const forceReselection = forceReselectionForTypes.includes(type);
 | 
			
		||||
 | 
			
		||||
            if (currentModelId && !forceReselection) {
 | 
			
		||||
                const provider = this.getProviderForModel(type, currentModelId);
 | 
			
		||||
                const apiKey = this.getApiKey(provider);
 | 
			
		||||
                // For Ollama, 'local' is a valid API key
 | 
			
		||||
@ -52,7 +71,7 @@ class ModelStateService {
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            if (!isCurrentModelValid) {
 | 
			
		||||
                console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`);
 | 
			
		||||
                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
 | 
			
		||||
@ -167,6 +186,9 @@ class ModelStateService {
 | 
			
		||||
            
 | 
			
		||||
            console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
 | 
			
		||||
            
 | 
			
		||||
            // Auto-select available models after loading state
 | 
			
		||||
            this._autoSelectAvailableModels();
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ModelStateService] Failed to load state from database:', error);
 | 
			
		||||
            // Fall back to default state
 | 
			
		||||
@ -326,13 +348,26 @@ class ModelStateService {
 | 
			
		||||
        this._logCurrentSelection();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setApiKey(provider, key) {
 | 
			
		||||
        if (provider in this.state.apiKeys) {
 | 
			
		||||
            this.state.apiKeys[provider] = key;
 | 
			
		||||
            this._saveState();
 | 
			
		||||
            return true;
 | 
			
		||||
    async setApiKey(provider, key) {
 | 
			
		||||
        console.log(`[ModelStateService] setApiKey: ${provider}`);
 | 
			
		||||
        if (!provider) {
 | 
			
		||||
            throw new Error('Provider is required');
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
        
 | 
			
		||||
        let finalKey = key;
 | 
			
		||||
        
 | 
			
		||||
        // Handle encryption for non-firebase providers
 | 
			
		||||
        if (provider !== 'firebase' && key && key !== 'local') {
 | 
			
		||||
            finalKey = await encryptionService.encrypt(key);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this.state.apiKeys[provider] = finalKey;
 | 
			
		||||
        await this._saveState();
 | 
			
		||||
        
 | 
			
		||||
        this._autoSelectAvailableModels([]);
 | 
			
		||||
        
 | 
			
		||||
        this._broadcastToAllWindows('model-state:updated', this.state);
 | 
			
		||||
        this._broadcastToAllWindows('settings-updated');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getApiKey(provider) {
 | 
			
		||||
@ -344,18 +379,14 @@ class ModelStateService {
 | 
			
		||||
        return displayKeys;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    removeApiKey(provider) {
 | 
			
		||||
        if (provider in this.state.apiKeys) {
 | 
			
		||||
    async removeApiKey(provider) {
 | 
			
		||||
        if (this.state.apiKeys[provider]) {
 | 
			
		||||
            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();
 | 
			
		||||
            await providerSettingsRepository.remove(provider);
 | 
			
		||||
            await this._saveState();
 | 
			
		||||
            this._autoSelectAvailableModels([]);
 | 
			
		||||
            this._broadcastToAllWindows('model-state:updated', this.state);
 | 
			
		||||
            this._broadcastToAllWindows('settings-updated');
 | 
			
		||||
            return true;
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
@ -392,6 +423,8 @@ class ModelStateService {
 | 
			
		||||
    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') {
 | 
			
		||||
@ -439,11 +472,36 @@ class ModelStateService {
 | 
			
		||||
        const available = [];
 | 
			
		||||
        const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
 | 
			
		||||
 | 
			
		||||
        Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
 | 
			
		||||
            if (key && PROVIDERS[providerId]?.[modelList]) {
 | 
			
		||||
        for (const [providerId, key] of Object.entries(this.state.apiKeys)) {
 | 
			
		||||
            if (!key) continue;
 | 
			
		||||
            
 | 
			
		||||
            // Ollama의 경우 데이터베이스에서 설치된 모델을 가져오기
 | 
			
		||||
            if (providerId === 'ollama' && type === 'llm') {
 | 
			
		||||
                try {
 | 
			
		||||
                    const ollamaModelRepository = require('../repositories/ollamaModel');
 | 
			
		||||
                    const installedModels = ollamaModelRepository.getInstalledModels();
 | 
			
		||||
                    const ollamaModels = installedModels.map(model => ({
 | 
			
		||||
                        id: model.name,
 | 
			
		||||
                        name: model.name
 | 
			
		||||
                    }));
 | 
			
		||||
                    available.push(...ollamaModels);
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.warn('[ModelStateService] Failed to get Ollama models from DB:', error.message);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            // Whisper의 경우 정적 모델 목록 사용 (설치 상태는 별도 확인)
 | 
			
		||||
            else if (providerId === 'whisper' && type === 'stt') {
 | 
			
		||||
                // Whisper 모델은 factory.js의 정적 목록 사용
 | 
			
		||||
                if (PROVIDERS[providerId]?.[modelList]) {
 | 
			
		||||
                    available.push(...PROVIDERS[providerId][modelList]);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            // 다른 provider들은 기존 로직 사용
 | 
			
		||||
            else if (PROVIDERS[providerId]?.[modelList]) {
 | 
			
		||||
                available.push(...PROVIDERS[providerId][modelList]);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        return [...new Map(available.map(item => [item.id, item])).values()];
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
@ -452,20 +510,31 @@ class ModelStateService {
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    setSelectedModel(type, modelId) {
 | 
			
		||||
        const provider = this.getProviderForModel(type, modelId);
 | 
			
		||||
        if (provider && this.state.apiKeys[provider]) {
 | 
			
		||||
            const previousModel = this.state.selectedModels[type];
 | 
			
		||||
            this.state.selectedModels[type] = modelId;
 | 
			
		||||
            this._saveState();
 | 
			
		||||
            
 | 
			
		||||
            // Auto warm-up for Ollama LLM models when changed
 | 
			
		||||
            if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
 | 
			
		||||
                this._autoWarmUpOllamaModel(modelId, previousModel);
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return true;
 | 
			
		||||
        const availableModels = this.getAvailableModels(type);
 | 
			
		||||
        const isAvailable = availableModels.some(model => model.id === modelId);
 | 
			
		||||
        
 | 
			
		||||
        if (!isAvailable) {
 | 
			
		||||
            console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`);
 | 
			
		||||
            return false;
 | 
			
		||||
        }
 | 
			
		||||
        return false;
 | 
			
		||||
        
 | 
			
		||||
        const previousModelId = this.state.selectedModels[type];
 | 
			
		||||
        this.state.selectedModels[type] = modelId;
 | 
			
		||||
        this._saveState();
 | 
			
		||||
        
 | 
			
		||||
        console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`);
 | 
			
		||||
        
 | 
			
		||||
        // Auto warm-up for Ollama models
 | 
			
		||||
        if (type === 'llm' && modelId && modelId !== previousModelId) {
 | 
			
		||||
            const provider = this.getProviderForModel('llm', modelId);
 | 
			
		||||
            if (provider === 'ollama') {
 | 
			
		||||
                this._autoWarmUpOllamaModel(modelId, previousModelId);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        this._broadcastToAllWindows('model-state:updated', this.state);
 | 
			
		||||
        this._broadcastToAllWindows('settings-updated');
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -476,7 +545,7 @@ class ModelStateService {
 | 
			
		||||
     */
 | 
			
		||||
    async _autoWarmUpOllamaModel(newModelId, previousModelId) {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[ModelStateService] 🔥 LLM model changed: ${previousModelId || 'None'} → ${newModelId}, triggering warm-up`);
 | 
			
		||||
            console.log(`[ModelStateService] LLM model changed: ${previousModelId || 'None'} → ${newModelId}, triggering warm-up`);
 | 
			
		||||
            
 | 
			
		||||
            // Get Ollama service if available
 | 
			
		||||
            const ollamaService = require('./ollamaService');
 | 
			
		||||
@ -492,12 +561,12 @@ class ModelStateService {
 | 
			
		||||
                    const success = await ollamaService.warmUpModel(newModelId);
 | 
			
		||||
                    
 | 
			
		||||
                    if (success) {
 | 
			
		||||
                        console.log(`[ModelStateService] ✅ Successfully warmed up model: ${newModelId}`);
 | 
			
		||||
                        console.log(`[ModelStateService] Successfully warmed up model: ${newModelId}`);
 | 
			
		||||
                    } else {
 | 
			
		||||
                        console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`);
 | 
			
		||||
                        console.log(`[ModelStateService] Failed to warm up model: ${newModelId}`);
 | 
			
		||||
                    }
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.log(`[ModelStateService] 🚫 Error during auto warm-up for ${newModelId}:`, error.message);
 | 
			
		||||
                    console.log(`[ModelStateService] Error during auto warm-up for ${newModelId}:`, error.message);
 | 
			
		||||
                }
 | 
			
		||||
            }, 500); // 500ms delay
 | 
			
		||||
            
 | 
			
		||||
@ -506,6 +575,41 @@ class ModelStateService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getProviderConfig() {
 | 
			
		||||
        const serializableProviders = {};
 | 
			
		||||
        for (const key in PROVIDERS) {
 | 
			
		||||
            const { handler, ...rest } = PROVIDERS[key];
 | 
			
		||||
            serializableProviders[key] = rest;
 | 
			
		||||
        }
 | 
			
		||||
        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) {
 | 
			
		||||
        console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`);
 | 
			
		||||
        const success = await this.removeApiKey(provider);
 | 
			
		||||
        if (success) {
 | 
			
		||||
            const selectedModels = this.getSelectedModels();
 | 
			
		||||
            if (!selectedModels.llm || !selectedModels.stt) {
 | 
			
		||||
                this._broadcastToAllWindows('force-show-apikey-header');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return success;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleSetSelectedModel(type, modelId) {
 | 
			
		||||
        return this.setSelectedModel(type, modelId);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
     * 
 | 
			
		||||
     * @param {('llm' | 'stt')} type
 | 
			
		||||
@ -527,55 +631,8 @@ class ModelStateService {
 | 
			
		||||
        return { provider, model, apiKey };
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    setupIpcHandlers() {
 | 
			
		||||
        ipcMain.handle('model:validate-key', async (e, { 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;
 | 
			
		||||
                this.setApiKey(provider, finalKey);
 | 
			
		||||
                // After setting the key, auto-select models
 | 
			
		||||
                this._autoSelectAvailableModels();
 | 
			
		||||
                this._saveState(); // Ensure state is saved after model selection
 | 
			
		||||
            }
 | 
			
		||||
            return result;
 | 
			
		||||
        });
 | 
			
		||||
        ipcMain.handle('model:get-all-keys', () => this.getAllApiKeys());
 | 
			
		||||
        ipcMain.handle('model:set-api-key', async (e, { provider, key }) => {
 | 
			
		||||
            const success = this.setApiKey(provider, key);
 | 
			
		||||
            if (success) {
 | 
			
		||||
                this._autoSelectAvailableModels();
 | 
			
		||||
                await this._saveState();
 | 
			
		||||
            }
 | 
			
		||||
            return success;
 | 
			
		||||
        });
 | 
			
		||||
        ipcMain.handle('model:remove-api-key', async (e, { provider }) => {
 | 
			
		||||
            const success = this.removeApiKey(provider);
 | 
			
		||||
            if (success) {
 | 
			
		||||
                const selectedModels = this.getSelectedModels();
 | 
			
		||||
                if (!selectedModels.llm || !selectedModels.stt) {
 | 
			
		||||
                    webContents.getAllWebContents().forEach(wc => {
 | 
			
		||||
                        wc.send('force-show-apikey-header');
 | 
			
		||||
                    });
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            return success;
 | 
			
		||||
        });
 | 
			
		||||
        ipcMain.handle('model:get-selected-models', () => this.getSelectedModels());
 | 
			
		||||
        ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => this.setSelectedModel(type, modelId));
 | 
			
		||||
        ipcMain.handle('model:get-available-models', (e, { type }) => this.getAvailableModels(type));
 | 
			
		||||
        ipcMain.handle('model:are-providers-configured', () => this.areProvidersConfigured());
 | 
			
		||||
        ipcMain.handle('model:get-current-model-info', (e, { type }) => this.getCurrentModelInfo(type));
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('model:get-provider-config', () => {
 | 
			
		||||
            const serializableProviders = {};
 | 
			
		||||
            for (const key in PROVIDERS) {
 | 
			
		||||
                const { handler, ...rest } = PROVIDERS[key];
 | 
			
		||||
                serializableProviders[key] = rest;
 | 
			
		||||
            }
 | 
			
		||||
            return serializableProviders;
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = ModelStateService;
 | 
			
		||||
// Export singleton instance
 | 
			
		||||
const modelStateService = new ModelStateService();
 | 
			
		||||
module.exports = modelStateService;
 | 
			
		||||
@ -3,10 +3,11 @@ const { promisify } = require('util');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
const path = require('path');
 | 
			
		||||
const fs = require('fs').promises;
 | 
			
		||||
const { app } = require('electron');
 | 
			
		||||
const { app, BrowserWindow } = require('electron');
 | 
			
		||||
const LocalAIServiceBase = require('./localAIServiceBase');
 | 
			
		||||
const { spawnAsync } = require('../utils/spawnHelper');
 | 
			
		||||
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
 | 
			
		||||
const ollamaModelRepository = require('../repositories/ollamaModel');
 | 
			
		||||
 | 
			
		||||
class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
    constructor() {
 | 
			
		||||
@ -26,8 +27,8 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        // Configuration
 | 
			
		||||
        this.requestTimeout = 8000; // 8s for health checks
 | 
			
		||||
        this.warmupTimeout = 15000; // 15s for model warmup
 | 
			
		||||
        this.requestTimeout = 0; // Delete timeout
 | 
			
		||||
        this.warmupTimeout = 120000; // 120s for model warmup
 | 
			
		||||
        this.healthCheckInterval = 60000; // 1min between health checks
 | 
			
		||||
        this.circuitBreakerThreshold = 3;
 | 
			
		||||
        this.circuitBreakerCooldown = 30000; // 30s
 | 
			
		||||
@ -39,6 +40,39 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
        this._startHealthMonitoring();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 모든 윈도우에 이벤트 브로드캐스트
 | 
			
		||||
    _broadcastToAllWindows(eventName, data = null) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (win && !win.isDestroyed()) {
 | 
			
		||||
                if (data !== null) {
 | 
			
		||||
                    win.webContents.send(eventName, data);
 | 
			
		||||
                } else {
 | 
			
		||||
                    win.webContents.send(eventName);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getStatus() {
 | 
			
		||||
        try {
 | 
			
		||||
            const installed = await this.isInstalled();
 | 
			
		||||
            if (!installed) {
 | 
			
		||||
                return { success: true, installed: false, running: false, models: [] };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const running = await this.isServiceRunning();
 | 
			
		||||
            if (!running) {
 | 
			
		||||
                return { success: true, installed: true, running: false, models: [] };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const models = await this.getInstalledModels();
 | 
			
		||||
            return { success: true, installed: true, running: true, models };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Error getting status:', error);
 | 
			
		||||
            return { success: false, error: error.message, installed: false, running: false, models: [] };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getOllamaCliPath() {
 | 
			
		||||
        if (this.getPlatform() === 'darwin') {
 | 
			
		||||
            return '/Applications/Ollama.app/Contents/Resources/ollama';
 | 
			
		||||
@ -66,14 +100,17 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
        const controller = new AbortController();
 | 
			
		||||
        const timeout = options.timeout || this.requestTimeout;
 | 
			
		||||
        
 | 
			
		||||
        // Set up timeout mechanism
 | 
			
		||||
        const timeoutId = setTimeout(() => {
 | 
			
		||||
            controller.abort();
 | 
			
		||||
            this.activeRequests.delete(requestId);
 | 
			
		||||
            this._recordFailure();
 | 
			
		||||
        }, timeout);
 | 
			
		||||
        
 | 
			
		||||
        this.requestTimeouts.set(requestId, timeoutId);
 | 
			
		||||
        // Set up timeout mechanism only if timeout > 0
 | 
			
		||||
        let timeoutId = null;
 | 
			
		||||
        if (timeout > 0) {
 | 
			
		||||
            timeoutId = setTimeout(() => {
 | 
			
		||||
                controller.abort();
 | 
			
		||||
                this.activeRequests.delete(requestId);
 | 
			
		||||
                this._recordFailure();
 | 
			
		||||
            }, timeout);
 | 
			
		||||
            
 | 
			
		||||
            this.requestTimeouts.set(requestId, timeoutId);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        const requestPromise = this._executeRequest(url, {
 | 
			
		||||
            ...options,
 | 
			
		||||
@ -94,8 +131,10 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
            }
 | 
			
		||||
            throw error;
 | 
			
		||||
        } finally {
 | 
			
		||||
            clearTimeout(timeoutId);
 | 
			
		||||
            this.requestTimeouts.delete(requestId);
 | 
			
		||||
            if (timeoutId !== null) {
 | 
			
		||||
                clearTimeout(timeoutId);
 | 
			
		||||
                this.requestTimeouts.delete(requestId);
 | 
			
		||||
            }
 | 
			
		||||
            this.activeRequests.delete(operationType === 'health' ? 'health' : requestId);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -356,7 +395,7 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
                            
 | 
			
		||||
                            if (progress !== null) {
 | 
			
		||||
                                this.setInstallProgress(modelName, progress);
 | 
			
		||||
                                this.emit('pull-progress', { 
 | 
			
		||||
                                this._broadcastToAllWindows('ollama:pull-progress', { 
 | 
			
		||||
                                    model: modelName, 
 | 
			
		||||
                                    progress,
 | 
			
		||||
                                    status: data.status || 'downloading'
 | 
			
		||||
@ -367,7 +406,7 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
                            // Handle completion
 | 
			
		||||
                            if (data.status === 'success') {
 | 
			
		||||
                                console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
 | 
			
		||||
                                this.emit('pull-complete', { model: modelName });
 | 
			
		||||
                                this._broadcastToAllWindows('ollama:pull-complete', { model: modelName });
 | 
			
		||||
                                this.clearInstallProgress(modelName);
 | 
			
		||||
                                resolve();
 | 
			
		||||
                                return;
 | 
			
		||||
@ -385,7 +424,7 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
                            const data = JSON.parse(buffer);
 | 
			
		||||
                            if (data.status === 'success') {
 | 
			
		||||
                                console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
 | 
			
		||||
                                this.emit('pull-complete', { model: modelName });
 | 
			
		||||
                                this._broadcastToAllWindows('ollama:pull-complete', { model: modelName });
 | 
			
		||||
                            }
 | 
			
		||||
                        } catch (parseError) {
 | 
			
		||||
                            console.warn('[OllamaService] Failed to parse final buffer:', buffer);
 | 
			
		||||
@ -618,8 +657,48 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
 | 
			
		||||
            return true;
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
 | 
			
		||||
            return false;
 | 
			
		||||
            // Check if it's a 404 error (model not found/installed)
 | 
			
		||||
            if (error.message.includes('HTTP 404') || error.message.includes('Not Found')) {
 | 
			
		||||
                console.log(`[OllamaService] Model ${modelName} not found (404), attempting to install...`);
 | 
			
		||||
                
 | 
			
		||||
                try {
 | 
			
		||||
                    // Try to install the model
 | 
			
		||||
                    await this.pullModel(modelName);
 | 
			
		||||
                    console.log(`[OllamaService] Successfully installed model ${modelName}, retrying warm-up...`);
 | 
			
		||||
                    
 | 
			
		||||
                    // Update database to reflect installation
 | 
			
		||||
                    await ollamaModelRepository.updateInstallStatus(modelName, true, false);
 | 
			
		||||
                    
 | 
			
		||||
                    // Retry warm-up after installation
 | 
			
		||||
                    const retryResponse = await this._makeRequest(`${this.baseUrl}/api/chat`, {
 | 
			
		||||
                        method: 'POST',
 | 
			
		||||
                        headers: { 'Content-Type': 'application/json' },
 | 
			
		||||
                        body: JSON.stringify({
 | 
			
		||||
                            model: modelName,
 | 
			
		||||
                            messages: [
 | 
			
		||||
                                { role: 'user', content: 'Hi' }
 | 
			
		||||
                            ],
 | 
			
		||||
                            stream: false,
 | 
			
		||||
                            options: {
 | 
			
		||||
                                num_predict: 1,
 | 
			
		||||
                                temperature: 0
 | 
			
		||||
                            }
 | 
			
		||||
                        }),
 | 
			
		||||
                        timeout: this.warmupTimeout
 | 
			
		||||
                    }, `warmup_retry_${modelName}`);
 | 
			
		||||
                    
 | 
			
		||||
                    console.log(`[OllamaService] Successfully warmed up model ${modelName} after installation`);
 | 
			
		||||
                    return true;
 | 
			
		||||
                    
 | 
			
		||||
                } catch (installError) {
 | 
			
		||||
                    console.error(`[OllamaService] Failed to auto-install model ${modelName}:`, installError.message);
 | 
			
		||||
                    await ollamaModelRepository.updateInstallStatus(modelName, false, false);
 | 
			
		||||
                    return false;
 | 
			
		||||
                }
 | 
			
		||||
            } else {
 | 
			
		||||
                console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -650,14 +729,8 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            // Check if model is installed
 | 
			
		||||
            const isInstalled = await this.isModelInstalled(llmModelId);
 | 
			
		||||
            if (!isInstalled) {
 | 
			
		||||
                console.log(`[OllamaService] Model ${llmModelId} not installed, skipping warm-up`);
 | 
			
		||||
                return false;
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId}`);
 | 
			
		||||
            // 설치 여부 체크 제거 - _performWarmUp에서 자동으로 설치 처리
 | 
			
		||||
            console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId} (will auto-install if needed)`);
 | 
			
		||||
            return await this.warmUpModel(llmModelId);
 | 
			
		||||
            
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
@ -802,6 +875,167 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
        
 | 
			
		||||
        return models;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleGetStatus() {
 | 
			
		||||
        try {
 | 
			
		||||
            const installed = await this.isInstalled();
 | 
			
		||||
            if (!installed) {
 | 
			
		||||
                return { success: true, installed: false, running: false, models: [] };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const running = await this.isServiceRunning();
 | 
			
		||||
            if (!running) {
 | 
			
		||||
                return { success: true, installed: true, running: false, models: [] };
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const models = await this.getAllModelsWithStatus();
 | 
			
		||||
            return { success: true, installed: true, running: true, models };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Error getting status:', error);
 | 
			
		||||
            return { success: false, error: error.message, installed: false, running: false, models: [] };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleInstall() {
 | 
			
		||||
        try {
 | 
			
		||||
            const onProgress = (data) => {
 | 
			
		||||
                this._broadcastToAllWindows('ollama:install-progress', data);
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await this.autoInstall(onProgress);
 | 
			
		||||
 | 
			
		||||
            if (!await this.isServiceRunning()) {
 | 
			
		||||
                onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 });
 | 
			
		||||
                await this.startService();
 | 
			
		||||
                onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 });
 | 
			
		||||
            }
 | 
			
		||||
            this._broadcastToAllWindows('ollama:install-complete', { success: true });
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to install:', error);
 | 
			
		||||
            this._broadcastToAllWindows('ollama:install-complete', { success: false, error: error.message });
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleStartService() {
 | 
			
		||||
        try {
 | 
			
		||||
            if (!await this.isServiceRunning()) {
 | 
			
		||||
                console.log('[OllamaService] Starting Ollama service...');
 | 
			
		||||
                await this.startService();
 | 
			
		||||
            }
 | 
			
		||||
            this.emit('install-complete', { success: true });
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to start service:', error);
 | 
			
		||||
            this.emit('install-complete', { success: false, error: error.message });
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleEnsureReady() {
 | 
			
		||||
        try {
 | 
			
		||||
            if (await this.isInstalled() && !await this.isServiceRunning()) {
 | 
			
		||||
                console.log('[OllamaService] Ollama installed but not running, starting service...');
 | 
			
		||||
                await this.startService();
 | 
			
		||||
            }
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to ensure ready:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleGetModels() {
 | 
			
		||||
        try {
 | 
			
		||||
            const models = await this.getAllModelsWithStatus();
 | 
			
		||||
            return { success: true, models };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to get models:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleGetModelSuggestions() {
 | 
			
		||||
        try {
 | 
			
		||||
            const suggestions = await this.getModelSuggestions();
 | 
			
		||||
            return { success: true, suggestions };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to get model suggestions:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handlePullModel(modelName) {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[OllamaService] Starting model pull: ${modelName}`);
 | 
			
		||||
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, false, true);
 | 
			
		||||
 | 
			
		||||
            await this.pullModel(modelName);
 | 
			
		||||
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, true, false);
 | 
			
		||||
 | 
			
		||||
            console.log(`[OllamaService] Model ${modelName} pull successful`);
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to pull model:', error);
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, false, false);
 | 
			
		||||
            this._broadcastToAllWindows('ollama:pull-error', { model: modelName, error: error.message });
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleIsModelInstalled(modelName) {
 | 
			
		||||
        try {
 | 
			
		||||
            const installed = await this.isModelInstalled(modelName);
 | 
			
		||||
            return { success: true, installed };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to check model installation:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleWarmUpModel(modelName) {
 | 
			
		||||
        try {
 | 
			
		||||
            const success = await this.warmUpModel(modelName);
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to warm up model:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleAutoWarmUp() {
 | 
			
		||||
        try {
 | 
			
		||||
            const success = await this.autoWarmUpSelectedModel();
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to auto warm-up:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleGetWarmUpStatus() {
 | 
			
		||||
        try {
 | 
			
		||||
            const status = this.getWarmUpStatus();
 | 
			
		||||
            return { success: true, status };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to get warm-up status:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleShutdown(force = false) {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[OllamaService] Manual shutdown requested (force: ${force})`);
 | 
			
		||||
            const success = await this.shutdown(force);
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to shutdown Ollama:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Export singleton instance
 | 
			
		||||
							
								
								
									
										119
									
								
								src/features/common/services/permissionService.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,119 @@
 | 
			
		||||
const { systemPreferences, shell, desktopCapturer } = require('electron');
 | 
			
		||||
const permissionRepository = require('../repositories/permission');
 | 
			
		||||
 | 
			
		||||
class PermissionService {
 | 
			
		||||
  async checkSystemPermissions() {
 | 
			
		||||
    const permissions = {
 | 
			
		||||
      microphone: 'unknown',
 | 
			
		||||
      screen: 'unknown',
 | 
			
		||||
      needsSetup: true
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (process.platform === 'darwin') {
 | 
			
		||||
        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.needsSetup = false;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      console.log('[Permissions] System permissions status:', permissions);
 | 
			
		||||
      return permissions;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error checking permissions:', error);
 | 
			
		||||
      return {
 | 
			
		||||
        microphone: 'unknown',
 | 
			
		||||
        screen: 'unknown',
 | 
			
		||||
        needsSetup: true,
 | 
			
		||||
        error: error.message
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async requestMicrophonePermission() {
 | 
			
		||||
    if (process.platform !== 'darwin') {
 | 
			
		||||
      return { success: true };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const status = systemPreferences.getMediaAccessStatus('microphone');
 | 
			
		||||
      console.log('[Permissions] Microphone status:', status);
 | 
			
		||||
      if (status === 'granted') {
 | 
			
		||||
        return { success: true, status: 'granted' };
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      const granted = await systemPreferences.askForMediaAccess('microphone');
 | 
			
		||||
      return {
 | 
			
		||||
        success: granted,
 | 
			
		||||
        status: granted ? 'granted' : 'denied'
 | 
			
		||||
      };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error requesting microphone permission:', error);
 | 
			
		||||
      return {
 | 
			
		||||
        success: false,
 | 
			
		||||
        error: error.message
 | 
			
		||||
      };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async openSystemPreferences(section) {
 | 
			
		||||
    if (process.platform !== 'darwin') {
 | 
			
		||||
      return { success: false, error: 'Not supported on this platform' };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      if (section === 'screen-recording') {
 | 
			
		||||
        try {
 | 
			
		||||
          console.log('[Permissions] Triggering screen capture request to register app...');
 | 
			
		||||
          await desktopCapturer.getSources({
 | 
			
		||||
            types: ['screen'],
 | 
			
		||||
            thumbnailSize: { width: 1, height: 1 }
 | 
			
		||||
          });
 | 
			
		||||
          console.log('[Permissions] App registered for screen recording');
 | 
			
		||||
        } catch (captureError) {
 | 
			
		||||
          console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message);
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        // await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
 | 
			
		||||
      }
 | 
			
		||||
      return { success: true };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error opening system preferences:', error);
 | 
			
		||||
      return { success: false, error: error.message };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async markPermissionsAsCompleted() {
 | 
			
		||||
    try {
 | 
			
		||||
      await permissionRepository.markPermissionsAsCompleted();
 | 
			
		||||
      console.log('[Permissions] Marked permissions as completed');
 | 
			
		||||
      return { success: true };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error marking permissions as completed:', error);
 | 
			
		||||
      return { success: false, error: error.message };
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkPermissionsCompleted() {
 | 
			
		||||
    try {
 | 
			
		||||
      const completed = await permissionRepository.checkPermissionsCompleted();
 | 
			
		||||
      console.log('[Permissions] Permissions completed status:', completed);
 | 
			
		||||
      return completed;
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
      console.error('[Permissions] Error checking permissions completed status:', error);
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const permissionService = new PermissionService();
 | 
			
		||||
module.exports = permissionService; 
 | 
			
		||||
@ -2,6 +2,7 @@ const { spawn } = require('child_process');
 | 
			
		||||
const path = require('path');
 | 
			
		||||
const fs = require('fs');
 | 
			
		||||
const os = require('os');
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const LocalAIServiceBase = require('./localAIServiceBase');
 | 
			
		||||
const { spawnAsync } = require('../utils/spawnHelper');
 | 
			
		||||
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
 | 
			
		||||
@ -39,6 +40,19 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 모든 윈도우에 이벤트 브로드캐스트
 | 
			
		||||
    _broadcastToAllWindows(eventName, data = null) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (win && !win.isDestroyed()) {
 | 
			
		||||
                if (data !== null) {
 | 
			
		||||
                    win.webContents.send(eventName, data);
 | 
			
		||||
                } else {
 | 
			
		||||
                    win.webContents.send(eventName);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async initialize() {
 | 
			
		||||
        if (this.isInitialized) return;
 | 
			
		||||
 | 
			
		||||
@ -157,18 +171,49 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
        const modelPath = await this.getModelPath(modelId);
 | 
			
		||||
        const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
 | 
			
		||||
        
 | 
			
		||||
        this.emit('downloadProgress', { modelId, progress: 0 });
 | 
			
		||||
        this._broadcastToAllWindows('whisper:download-progress', { modelId, progress: 0 });
 | 
			
		||||
        
 | 
			
		||||
        await this.downloadWithRetry(modelInfo.url, modelPath, {
 | 
			
		||||
            expectedChecksum: checksumInfo?.sha256,
 | 
			
		||||
            modelId, // modelId를 전달하여 LocalAIServiceBase에서 이벤트 발생 시 사용
 | 
			
		||||
            onProgress: (progress) => {
 | 
			
		||||
                this.emit('downloadProgress', { modelId, progress });
 | 
			
		||||
                this._broadcastToAllWindows('whisper:download-progress', { modelId, progress });
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
 | 
			
		||||
        this._broadcastToAllWindows('whisper:download-complete', { modelId });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleDownloadModel(modelId) {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[WhisperService] Handling download for model: ${modelId}`);
 | 
			
		||||
 | 
			
		||||
            if (!this.isInitialized) {
 | 
			
		||||
                await this.initialize();
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            await this.ensureModelAvailable(modelId);
 | 
			
		||||
            
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error(`[WhisperService] Failed to handle download for model ${modelId}:`, error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleGetInstalledModels() {
 | 
			
		||||
        try {
 | 
			
		||||
            if (!this.isInitialized) {
 | 
			
		||||
                await this.initialize();
 | 
			
		||||
            }
 | 
			
		||||
            const models = await this.getInstalledModels();
 | 
			
		||||
            return { success: true, models };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[WhisperService] Failed to get installed models:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async getModelPath(modelId) {
 | 
			
		||||
        if (!this.isInitialized || !this.modelsDir) {
 | 
			
		||||
@ -448,4 +493,6 @@ class WhisperService extends LocalAIServiceBase {
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = { WhisperService };
 | 
			
		||||
// Export singleton instance
 | 
			
		||||
const whisperService = new WhisperService();
 | 
			
		||||
module.exports = whisperService;
 | 
			
		||||
@ -1,8 +1,8 @@
 | 
			
		||||
const { BrowserWindow, app } = require('electron');
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const SttService = require('./stt/sttService');
 | 
			
		||||
const SummaryService = require('./summary/summaryService');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
const authService = require('../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../common/repositories/session');
 | 
			
		||||
const sttRepository = require('./stt/repositories');
 | 
			
		||||
 | 
			
		||||
class ListenService {
 | 
			
		||||
@ -11,8 +11,9 @@ class ListenService {
 | 
			
		||||
        this.summaryService = new SummaryService();
 | 
			
		||||
        this.currentSessionId = null;
 | 
			
		||||
        this.isInitializingSession = false;
 | 
			
		||||
        
 | 
			
		||||
 | 
			
		||||
        this.setupServiceCallbacks();
 | 
			
		||||
        console.log('[ListenService] Service instance created.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setupServiceCallbacks() {
 | 
			
		||||
@ -38,11 +39,58 @@ class ListenService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
        const { windowPool } = require('../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool?.get('listen');
 | 
			
		||||
        
 | 
			
		||||
        if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
            listenWindow.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize() {
 | 
			
		||||
        this.setupIpcHandlers();
 | 
			
		||||
        console.log('[ListenService] Initialized and ready.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleListenRequest(listenButtonText) {
 | 
			
		||||
        const { windowPool, updateLayout } = require('../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool.get('listen');
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            switch (listenButtonText) {
 | 
			
		||||
                case 'Listen':
 | 
			
		||||
                    console.log('[ListenService] changeSession to "Listen"');
 | 
			
		||||
                    listenWindow.show();
 | 
			
		||||
                    updateLayout();
 | 
			
		||||
                    listenWindow.webContents.send('window-show-animation');
 | 
			
		||||
                    await this.initializeSession();
 | 
			
		||||
                    listenWindow.webContents.send('session-state-changed', { isActive: true });
 | 
			
		||||
                    break;
 | 
			
		||||
        
 | 
			
		||||
                case 'Stop':
 | 
			
		||||
                    console.log('[ListenService] changeSession to "Stop"');
 | 
			
		||||
                    await this.closeSession();
 | 
			
		||||
                    listenWindow.webContents.send('session-state-changed', { isActive: false });
 | 
			
		||||
                    break;
 | 
			
		||||
        
 | 
			
		||||
                case 'Done':
 | 
			
		||||
                    console.log('[ListenService] changeSession to "Done"');
 | 
			
		||||
                    listenWindow.webContents.send('window-hide-animation');
 | 
			
		||||
                    listenWindow.webContents.send('session-state-changed', { isActive: false });
 | 
			
		||||
                    break;
 | 
			
		||||
        
 | 
			
		||||
                default:
 | 
			
		||||
                    throw new Error(`[ListenService] unknown listenButtonText: ${listenButtonText}`);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
            
 | 
			
		||||
            header.webContents.send('listen:changeSessionResult', { success: true });
 | 
			
		||||
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[ListenService] error in handleListenRequest:', error);
 | 
			
		||||
            header.webContents.send('listen:changeSessionResult', { success: false });
 | 
			
		||||
            throw error; 
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleTranscriptionComplete(speaker, text) {
 | 
			
		||||
@ -158,8 +206,8 @@ class ListenService {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async sendAudioContent(data, mimeType) {
 | 
			
		||||
        return await this.sttService.sendAudioContent(data, mimeType);
 | 
			
		||||
    async sendMicAudioContent(data, mimeType) {
 | 
			
		||||
        return await this.sttService.sendMicAudioContent(data, mimeType);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async startMacOSAudioCapture() {
 | 
			
		||||
@ -183,6 +231,8 @@ class ListenService {
 | 
			
		||||
            // Close STT sessions
 | 
			
		||||
            await this.sttService.closeSessions();
 | 
			
		||||
 | 
			
		||||
            await this.stopMacOSAudioCapture();
 | 
			
		||||
 | 
			
		||||
            // End database session
 | 
			
		||||
            if (this.currentSessionId) {
 | 
			
		||||
                await sessionRepository.end(this.currentSessionId);
 | 
			
		||||
@ -193,8 +243,6 @@ class ListenService {
 | 
			
		||||
            this.currentSessionId = null;
 | 
			
		||||
            this.summaryService.resetConversationHistory();
 | 
			
		||||
 | 
			
		||||
            this.sendToRenderer('session-did-close');
 | 
			
		||||
 | 
			
		||||
            console.log('Listen service session closed.');
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
@ -216,88 +264,58 @@ class ListenService {
 | 
			
		||||
        return this.summaryService.getConversationHistory();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    setupIpcHandlers() {
 | 
			
		||||
        const { ipcMain } = require('electron');
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('is-session-active', async () => {
 | 
			
		||||
            const isActive = this.isSessionActive();
 | 
			
		||||
            console.log(`Checking session status. Active: ${isActive}`);
 | 
			
		||||
            return isActive;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
 | 
			
		||||
            console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
 | 
			
		||||
            const success = await this.initializeSession(language);
 | 
			
		||||
            return success;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
 | 
			
		||||
    _createHandler(asyncFn, successMessage, errorMessage) {
 | 
			
		||||
        return async (...args) => {
 | 
			
		||||
            try {
 | 
			
		||||
                await this.sendAudioContent(data, mimeType);
 | 
			
		||||
                return { success: true };
 | 
			
		||||
                const result = await asyncFn.apply(this, args);
 | 
			
		||||
                if (successMessage) console.log(successMessage);
 | 
			
		||||
                // `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로,
 | 
			
		||||
                // 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다.
 | 
			
		||||
                // 다른 함수들은 이미 success 객체를 반환합니다.
 | 
			
		||||
                return result && typeof result.success !== 'undefined' ? result : { success: true };
 | 
			
		||||
            } catch (e) {
 | 
			
		||||
                console.error('Error sending user audio:', e);
 | 
			
		||||
                console.error(errorMessage, e);
 | 
			
		||||
                return { success: false, error: e.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => {
 | 
			
		||||
            try {
 | 
			
		||||
                await this.sttService.sendSystemAudioContent(data, mimeType);
 | 
			
		||||
                
 | 
			
		||||
                // Send system audio data back to renderer for AEC reference (like macOS does)
 | 
			
		||||
                this.sendToRenderer('system-audio-data', { data });
 | 
			
		||||
                
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error sending system audio:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    // `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다.
 | 
			
		||||
    handleSendMicAudioContent = this._createHandler(
 | 
			
		||||
        this.sendMicAudioContent,
 | 
			
		||||
        null,
 | 
			
		||||
        'Error sending user audio:'
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('start-macos-audio', async () => {
 | 
			
		||||
    handleStartMacosAudio = this._createHandler(
 | 
			
		||||
        async () => {
 | 
			
		||||
            if (process.platform !== 'darwin') {
 | 
			
		||||
                return { success: false, error: 'macOS audio capture only available on macOS' };
 | 
			
		||||
            }
 | 
			
		||||
            if (this.sttService.isMacOSAudioRunning?.()) {
 | 
			
		||||
                return { success: false, error: 'already_running' };
 | 
			
		||||
            }
 | 
			
		||||
            await this.startMacOSAudioCapture();
 | 
			
		||||
            return { success: true, error: null };
 | 
			
		||||
        },
 | 
			
		||||
        'macOS audio capture started.',
 | 
			
		||||
        'Error starting macOS audio capture:'
 | 
			
		||||
    );
 | 
			
		||||
    
 | 
			
		||||
    handleStopMacosAudio = this._createHandler(
 | 
			
		||||
        this.stopMacOSAudioCapture,
 | 
			
		||||
        'macOS audio capture stopped.',
 | 
			
		||||
        'Error stopping macOS audio capture:'
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
            try {
 | 
			
		||||
                const success = await this.startMacOSAudioCapture();
 | 
			
		||||
                return { success, error: null };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error starting macOS audio capture:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('stop-macos-audio', async () => {
 | 
			
		||||
            try {
 | 
			
		||||
                this.stopMacOSAudioCapture();
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error stopping macOS audio capture:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // ipcMain.handle('close-session', async () => {
 | 
			
		||||
        //     return await this.closeSession();
 | 
			
		||||
        // });
 | 
			
		||||
 | 
			
		||||
        ipcMain.handle('update-google-search-setting', async (event, enabled) => {
 | 
			
		||||
            try {
 | 
			
		||||
                console.log('Google Search setting updated to:', enabled);
 | 
			
		||||
                return { success: true };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Error updating Google Search setting:', error);
 | 
			
		||||
                return { success: false, error: error.message };
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        console.log('✅ Listen service IPC handlers registered');
 | 
			
		||||
    }
 | 
			
		||||
    handleUpdateGoogleSearchSetting = this._createHandler(
 | 
			
		||||
        async (enabled) => {
 | 
			
		||||
            console.log('Google Search setting updated to:', enabled);
 | 
			
		||||
        },
 | 
			
		||||
        null,
 | 
			
		||||
        'Error updating Google Search setting:'
 | 
			
		||||
    );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = ListenService;
 | 
			
		||||
const listenService = new ListenService();
 | 
			
		||||
module.exports = listenService;
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
 | 
			
		||||
const { getFirestoreInstance } = require('../../../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter');
 | 
			
		||||
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
 | 
			
		||||
 | 
			
		||||
const transcriptConverter = createEncryptedConverter(['text']);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const sqliteRepository = require('./sqlite.repository');
 | 
			
		||||
const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
const authService = require('../../../../common/services/authService');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
const sqliteClient = require('../../../../common/services/sqliteClient');
 | 
			
		||||
const sqliteClient = require('../../../common/services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function addTranscript({ uid, sessionId, speaker, text }) {
 | 
			
		||||
    // uid is ignored in the SQLite implementation
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,8 @@
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { spawn } = require('child_process');
 | 
			
		||||
const { createSTT } = require('../../../common/ai/factory');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager');
 | 
			
		||||
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,11 +35,24 @@ class SttService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        // Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
 | 
			
		||||
        const { windowPool } = require('../../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool?.get('listen');
 | 
			
		||||
        
 | 
			
		||||
        if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
            listenWindow.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleSendSystemAudioContent(data, mimeType) {
 | 
			
		||||
        try {
 | 
			
		||||
            await this.sendSystemAudioContent(data, mimeType);
 | 
			
		||||
            this.sendToRenderer('system-audio-data', { data });
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('Error sending system audio:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    flushMyCompletion() {
 | 
			
		||||
@ -120,7 +134,7 @@ class SttService {
 | 
			
		||||
    async initializeSttSessions(language = 'en') {
 | 
			
		||||
        const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
 | 
			
		||||
 | 
			
		||||
        const modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
 | 
			
		||||
        const modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
            throw new Error('AI model or API key is not configured.');
 | 
			
		||||
        }
 | 
			
		||||
@ -132,6 +146,7 @@ class SttService {
 | 
			
		||||
                console.log('[SttService] Ignoring message - session already closed');
 | 
			
		||||
                return;
 | 
			
		||||
            }
 | 
			
		||||
            console.log('[SttService] handleMyMessage', message);
 | 
			
		||||
            
 | 
			
		||||
            if (this.modelInfo.provider === 'whisper') {
 | 
			
		||||
                // Whisper STT emits 'transcription' events with different structure
 | 
			
		||||
@ -367,11 +382,6 @@ class SttService {
 | 
			
		||||
                onclose: event => console.log('Their STT session closed:', event.reason),
 | 
			
		||||
            },
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // Determine auth options for providers that support it
 | 
			
		||||
        // const authService = require('../../../common/services/authService');
 | 
			
		||||
        // const userState = authService.getCurrentUser();
 | 
			
		||||
        // const loggedIn = userState.isLoggedIn;
 | 
			
		||||
        
 | 
			
		||||
        const sttOptions = {
 | 
			
		||||
            apiKey: this.modelInfo.apiKey,
 | 
			
		||||
@ -393,7 +403,7 @@ class SttService {
 | 
			
		||||
        return true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async sendAudioContent(data, mimeType) {
 | 
			
		||||
    async sendMicAudioContent(data, mimeType) {
 | 
			
		||||
        // const provider = await this.getAiProvider();
 | 
			
		||||
        // const isGemini = provider === 'gemini';
 | 
			
		||||
        
 | 
			
		||||
@ -404,7 +414,7 @@ class SttService {
 | 
			
		||||
        let modelInfo = this.modelInfo;
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
 | 
			
		||||
            modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
 | 
			
		||||
            modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            throw new Error('STT model info could not be retrieved.');
 | 
			
		||||
@ -425,7 +435,7 @@ class SttService {
 | 
			
		||||
        let modelInfo = this.modelInfo;
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
 | 
			
		||||
            modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
 | 
			
		||||
            modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            throw new Error('STT model info could not be retrieved.');
 | 
			
		||||
@ -476,8 +486,8 @@ class SttService {
 | 
			
		||||
        const { app } = require('electron');
 | 
			
		||||
        const path = require('path');
 | 
			
		||||
        const systemAudioPath = app.isPackaged
 | 
			
		||||
            ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump')
 | 
			
		||||
            : path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump');
 | 
			
		||||
            ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'ui', 'assets', 'SystemAudioDump')
 | 
			
		||||
            : path.join(app.getAppPath(), 'src', 'ui', 'assets', 'SystemAudioDump');
 | 
			
		||||
 | 
			
		||||
        console.log('SystemAudioDump path:', systemAudioPath);
 | 
			
		||||
 | 
			
		||||
@ -506,7 +516,7 @@ class SttService {
 | 
			
		||||
        let modelInfo = this.modelInfo;
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
 | 
			
		||||
            modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
 | 
			
		||||
            modelInfo = modelStateService.getCurrentModelInfo('stt');
 | 
			
		||||
        }
 | 
			
		||||
        if (!modelInfo) {
 | 
			
		||||
            throw new Error('STT model info could not be retrieved.');
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore');
 | 
			
		||||
const { getFirestoreInstance } = require('../../../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter');
 | 
			
		||||
const encryptionService = require('../../../../common/services/encryptionService');
 | 
			
		||||
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
 | 
			
		||||
const encryptionService = require('../../../common/services/encryptionService');
 | 
			
		||||
 | 
			
		||||
const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json'];
 | 
			
		||||
const summaryConverter = createEncryptedConverter(fieldsToEncrypt);
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const sqliteRepository = require('./sqlite.repository');
 | 
			
		||||
const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
const authService = require('../../../../common/services/authService');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
const sqliteClient = require('../../../../common/services/sqliteClient');
 | 
			
		||||
const sqliteClient = require('../../../common/services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) {
 | 
			
		||||
    // uid is ignored in the SQLite implementation
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
const { BrowserWindow } = require('electron');
 | 
			
		||||
const { getSystemPrompt } = require('../../../common/prompts/promptBuilder.js');
 | 
			
		||||
const { createLLM } = require('../../../common/ai/factory');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
const sessionRepository = require('../../../common/repositories/session');
 | 
			
		||||
const { getSystemPrompt } = require('../../common/prompts/promptBuilder.js');
 | 
			
		||||
const { createLLM } = require('../../common/ai/factory');
 | 
			
		||||
const sessionRepository = require('../../common/repositories/session');
 | 
			
		||||
const summaryRepository = require('./repositories');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager');
 | 
			
		||||
const modelStateService = require('../../common/services/modelStateService');
 | 
			
		||||
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js');
 | 
			
		||||
 | 
			
		||||
class SummaryService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
@ -28,11 +28,12 @@ class SummaryService {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    sendToRenderer(channel, data) {
 | 
			
		||||
        BrowserWindow.getAllWindows().forEach(win => {
 | 
			
		||||
            if (!win.isDestroyed()) {
 | 
			
		||||
                win.webContents.send(channel, data);
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        const { windowPool } = require('../../../window/windowManager');
 | 
			
		||||
        const listenWindow = windowPool?.get('listen');
 | 
			
		||||
        
 | 
			
		||||
        if (listenWindow && !listenWindow.isDestroyed()) {
 | 
			
		||||
            listenWindow.webContents.send(channel, data);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    addConversationTurn(speaker, text) {
 | 
			
		||||
@ -98,7 +99,7 @@ Please build upon this context while analyzing the new conversation segments.
 | 
			
		||||
                await sessionRepository.touch(this.currentSessionId);
 | 
			
		||||
            }
 | 
			
		||||
 | 
			
		||||
            const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
 | 
			
		||||
            const modelInfo = modelStateService.getCurrentModelInfo('llm');
 | 
			
		||||
            if (!modelInfo || !modelInfo.apiKey) {
 | 
			
		||||
                throw new Error('AI model or API key is not configured.');
 | 
			
		||||
            }
 | 
			
		||||
@ -304,25 +305,20 @@ 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 (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`);
 | 
			
		||||
            console.log(`Triggering analysis - ${this.conversationHistory.length} conversation texts accumulated`);
 | 
			
		||||
 | 
			
		||||
            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);
 | 
			
		||||
                });
 | 
			
		||||
            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');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore');
 | 
			
		||||
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
 | 
			
		||||
const encryptionService = require('../../../common/services/encryptionService');
 | 
			
		||||
const { getFirestoreInstance } = require('../../common/services/firebaseClient');
 | 
			
		||||
const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter');
 | 
			
		||||
const encryptionService = require('../../common/services/encryptionService');
 | 
			
		||||
 | 
			
		||||
const userPresetConverter = createEncryptedConverter(['prompt', 'title']);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,6 @@
 | 
			
		||||
const sqliteRepository = require('./sqlite.repository');
 | 
			
		||||
const firebaseRepository = require('./firebase.repository');
 | 
			
		||||
const authService = require('../../../common/services/authService');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
 | 
			
		||||
function getBaseRepository() {
 | 
			
		||||
    const user = authService.getCurrentUser();
 | 
			
		||||
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
const sqliteClient = require('../../../common/services/sqliteClient');
 | 
			
		||||
const sqliteClient = require('../../common/services/sqliteClient');
 | 
			
		||||
 | 
			
		||||
function getPresets(uid) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,13 @@
 | 
			
		||||
const { ipcMain, BrowserWindow } = require('electron');
 | 
			
		||||
const Store = require('electron-store');
 | 
			
		||||
const authService = require('../../common/services/authService');
 | 
			
		||||
const authService = require('../common/services/authService');
 | 
			
		||||
const settingsRepository = require('./repositories');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../electron/windowManager');
 | 
			
		||||
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager');
 | 
			
		||||
 | 
			
		||||
// New imports for common services
 | 
			
		||||
const modelStateService = require('../common/services/modelStateService');
 | 
			
		||||
const ollamaService = require('../common/services/ollamaService');
 | 
			
		||||
const whisperService = require('../common/services/whisperService');
 | 
			
		||||
 | 
			
		||||
const store = new Store({
 | 
			
		||||
    name: 'pickle-glass-settings',
 | 
			
		||||
@ -19,6 +24,54 @@ const NOTIFICATION_CONFIG = {
 | 
			
		||||
    RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
// New facade functions for model state management
 | 
			
		||||
async function getModelSettings() {
 | 
			
		||||
    try {
 | 
			
		||||
        const [config, storedKeys, selectedModels] = await Promise.all([
 | 
			
		||||
            modelStateService.getProviderConfig(),
 | 
			
		||||
            modelStateService.getAllApiKeys(),
 | 
			
		||||
            modelStateService.getSelectedModels(),
 | 
			
		||||
        ]);
 | 
			
		||||
        
 | 
			
		||||
        // 동기 함수들은 별도로 호출
 | 
			
		||||
        const availableLlm = modelStateService.getAvailableModels('llm');
 | 
			
		||||
        const availableStt = modelStateService.getAvailableModels('stt');
 | 
			
		||||
        
 | 
			
		||||
        return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[SettingsService] Error getting model settings:', error);
 | 
			
		||||
        return { success: false, error: error.message };
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function validateAndSaveKey(provider, key) {
 | 
			
		||||
    return modelStateService.handleValidateKey(provider, key);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function clearApiKey(provider) {
 | 
			
		||||
    const success = await modelStateService.handleRemoveApiKey(provider);
 | 
			
		||||
    return { success };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function setSelectedModel(type, modelId) {
 | 
			
		||||
    const success = await modelStateService.handleSetSelectedModel(type, modelId);
 | 
			
		||||
    return { success };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Ollama facade functions
 | 
			
		||||
async function getOllamaStatus() {
 | 
			
		||||
    return ollamaService.getStatus();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function ensureOllamaReady() {
 | 
			
		||||
    return ollamaService.ensureReady();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function shutdownOllama() {
 | 
			
		||||
    return ollamaService.shutdown(false); // false for graceful shutdown
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
// window targeting system
 | 
			
		||||
class WindowNotificationManager {
 | 
			
		||||
    constructor() {
 | 
			
		||||
@ -324,6 +377,7 @@ async function removeApiKey() {
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
        
 | 
			
		||||
        console.log('[SettingsService] API key removed for all providers');
 | 
			
		||||
        return { success: true };
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[SettingsService] Error removing API key:', error);
 | 
			
		||||
@ -373,57 +427,6 @@ function initialize() {
 | 
			
		||||
    // cleanup 
 | 
			
		||||
    windowNotificationManager.cleanup();
 | 
			
		||||
    
 | 
			
		||||
    // IPC handlers for settings
 | 
			
		||||
    ipcMain.handle('settings:getSettings', async () => {
 | 
			
		||||
        return await getSettings();
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:saveSettings', async (event, settings) => {
 | 
			
		||||
        return await saveSettings(settings);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // IPC handlers for presets
 | 
			
		||||
    ipcMain.handle('settings:getPresets', async () => {
 | 
			
		||||
        return await getPresets();
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:getPresetTemplates', async () => {
 | 
			
		||||
        return await getPresetTemplates();
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:createPreset', async (event, title, prompt) => {
 | 
			
		||||
        return await createPreset(title, prompt);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => {
 | 
			
		||||
        return await updatePreset(id, title, prompt);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:deletePreset', async (event, id) => {
 | 
			
		||||
        return await deletePreset(id);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:saveApiKey', async (event, apiKey, provider) => {
 | 
			
		||||
        return await saveApiKey(apiKey, provider);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:removeApiKey', async () => {
 | 
			
		||||
        return await removeApiKey();
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('settings:updateContentProtection', async (event, enabled) => {
 | 
			
		||||
        return await updateContentProtection(enabled);
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('settings:get-auto-update', async () => {
 | 
			
		||||
        return await getAutoUpdateSetting();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => {
 | 
			
		||||
        console.log('[SettingsService] Setting auto update setting:', isEnabled);
 | 
			
		||||
        return await setAutoUpdateSetting(isEnabled);
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    console.log('[SettingsService] Initialized and ready.');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -455,4 +458,14 @@ module.exports = {
 | 
			
		||||
    removeApiKey,
 | 
			
		||||
    updateContentProtection,
 | 
			
		||||
    getAutoUpdateSetting,
 | 
			
		||||
    setAutoUpdateSetting,
 | 
			
		||||
    // Model settings facade
 | 
			
		||||
    getModelSettings,
 | 
			
		||||
    validateAndSaveKey,
 | 
			
		||||
    clearApiKey,
 | 
			
		||||
    setSelectedModel,
 | 
			
		||||
    // Ollama facade
 | 
			
		||||
    getOllamaStatus,
 | 
			
		||||
    ensureOllamaReady,
 | 
			
		||||
    shutdownOllama
 | 
			
		||||
};
 | 
			
		||||
							
								
								
									
										1
									
								
								src/features/shortcuts/repositories/index.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1 @@
 | 
			
		||||
module.exports = require('./sqlite.repository'); 
 | 
			
		||||
							
								
								
									
										48
									
								
								src/features/shortcuts/repositories/sqlite.repository.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,48 @@
 | 
			
		||||
const sqliteClient = require('../../common/services/sqliteClient');
 | 
			
		||||
const crypto = require('crypto');
 | 
			
		||||
 | 
			
		||||
function getAllKeybinds() {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const query = 'SELECT * FROM shortcuts';
 | 
			
		||||
    try {
 | 
			
		||||
        return db.prepare(query).all();
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error(`[DB] Failed to get keybinds:`, error);
 | 
			
		||||
        return [];
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function upsertKeybinds(keybinds) {
 | 
			
		||||
    if (!keybinds || keybinds.length === 0) return;
 | 
			
		||||
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const upsert = db.transaction((items) => {
 | 
			
		||||
        const query = `
 | 
			
		||||
            INSERT INTO shortcuts (action, accelerator, created_at)
 | 
			
		||||
            VALUES (@action, @accelerator, @created_at)
 | 
			
		||||
            ON CONFLICT(action) DO UPDATE SET
 | 
			
		||||
                accelerator = excluded.accelerator;
 | 
			
		||||
        `;
 | 
			
		||||
        const insert = db.prepare(query);
 | 
			
		||||
 | 
			
		||||
        for (const item of items) {
 | 
			
		||||
            insert.run({
 | 
			
		||||
                action: item.action,
 | 
			
		||||
                accelerator: item.accelerator,
 | 
			
		||||
                created_at: Math.floor(Date.now() / 1000)
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
        upsert(keybinds);
 | 
			
		||||
    } catch (error) {
 | 
			
		||||
        console.error('[DB] Failed to upsert keybinds:', error);
 | 
			
		||||
        throw error;
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = {
 | 
			
		||||
    getAllKeybinds,
 | 
			
		||||
    upsertKeybinds
 | 
			
		||||
}; 
 | 
			
		||||
							
								
								
									
										285
									
								
								src/features/shortcuts/shortcutsService.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,285 @@
 | 
			
		||||
const { globalShortcut, screen } = require('electron');
 | 
			
		||||
const shortcutsRepository = require('./repositories');
 | 
			
		||||
const internalBridge = require('../../bridge/internalBridge');
 | 
			
		||||
const askService = require('../ask/askService');
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class ShortcutsService {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.lastVisibleWindows = new Set(['header']);
 | 
			
		||||
        this.mouseEventsIgnored = false;
 | 
			
		||||
        this.movementManager = null;
 | 
			
		||||
        this.windowPool = null;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize(movementManager, windowPool) {
 | 
			
		||||
        this.movementManager = movementManager;
 | 
			
		||||
        this.windowPool = windowPool;
 | 
			
		||||
        internalBridge.on('reregister-shortcuts', () => {
 | 
			
		||||
            console.log('[ShortcutsService] Reregistering shortcuts due to header state change.');
 | 
			
		||||
            this.registerShortcuts();
 | 
			
		||||
        });
 | 
			
		||||
        console.log('[ShortcutsService] Initialized with dependencies and event listener.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getDefaultKeybinds() {
 | 
			
		||||
        const isMac = process.platform === 'darwin';
 | 
			
		||||
        return {
 | 
			
		||||
            moveUp: isMac ? 'Cmd+Up' : 'Ctrl+Up',
 | 
			
		||||
            moveDown: isMac ? 'Cmd+Down' : 'Ctrl+Down',
 | 
			
		||||
            moveLeft: isMac ? 'Cmd+Left' : 'Ctrl+Left',
 | 
			
		||||
            moveRight: isMac ? 'Cmd+Right' : 'Ctrl+Right',
 | 
			
		||||
            toggleVisibility: isMac ? 'Cmd+\\' : 'Ctrl+\\',
 | 
			
		||||
            toggleClickThrough: isMac ? 'Cmd+M' : 'Ctrl+M',
 | 
			
		||||
            nextStep: isMac ? 'Cmd+Enter' : 'Ctrl+Enter',
 | 
			
		||||
            manualScreenshot: isMac ? 'Cmd+Shift+S' : 'Ctrl+Shift+S',
 | 
			
		||||
            previousResponse: isMac ? 'Cmd+[' : 'Ctrl+[',
 | 
			
		||||
            nextResponse: isMac ? 'Cmd+]' : 'Ctrl+]',
 | 
			
		||||
            scrollUp: isMac ? 'Cmd+Shift+Up' : 'Ctrl+Shift+Up',
 | 
			
		||||
            scrollDown: isMac ? 'Cmd+Shift+Down' : 'Ctrl+Shift+Down',
 | 
			
		||||
        };
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async loadKeybinds() {
 | 
			
		||||
        let keybindsArray = await shortcutsRepository.getAllKeybinds();
 | 
			
		||||
 | 
			
		||||
        if (!keybindsArray || keybindsArray.length === 0) {
 | 
			
		||||
            console.log(`[Shortcuts] No keybinds found. Loading defaults.`);
 | 
			
		||||
            const defaults = this.getDefaultKeybinds();
 | 
			
		||||
            await this.saveKeybinds(defaults); 
 | 
			
		||||
            return defaults;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const keybinds = {};
 | 
			
		||||
        keybindsArray.forEach(k => {
 | 
			
		||||
            keybinds[k.action] = k.accelerator;
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const defaults = this.getDefaultKeybinds();
 | 
			
		||||
        let needsUpdate = false;
 | 
			
		||||
        for (const action in defaults) {
 | 
			
		||||
            if (!keybinds[action]) {
 | 
			
		||||
                keybinds[action] = defaults[action];
 | 
			
		||||
                needsUpdate = true;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (needsUpdate) {
 | 
			
		||||
            console.log('[Shortcuts] Updating missing keybinds with defaults.');
 | 
			
		||||
            await this.saveKeybinds(keybinds);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        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) {
 | 
			
		||||
            if (Object.prototype.hasOwnProperty.call(newKeybinds, action)) {
 | 
			
		||||
                keybindsToSave.push({
 | 
			
		||||
                    action: action,
 | 
			
		||||
                    accelerator: newKeybinds[action],
 | 
			
		||||
                });
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        await shortcutsRepository.upsertKeybinds(keybindsToSave);
 | 
			
		||||
        console.log(`[Shortcuts] Saved keybinds.`);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    toggleAllWindowsVisibility(windowPool) {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (!header) return;
 | 
			
		||||
      
 | 
			
		||||
        if (header.isVisible()) {
 | 
			
		||||
            this.lastVisibleWindows.clear();
 | 
			
		||||
      
 | 
			
		||||
            windowPool.forEach((win, name) => {
 | 
			
		||||
                if (win && !win.isDestroyed() && win.isVisible()) {
 | 
			
		||||
                    this.lastVisibleWindows.add(name);
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
      
 | 
			
		||||
            this.lastVisibleWindows.forEach(name => {
 | 
			
		||||
                if (name === 'header') return;
 | 
			
		||||
                const win = windowPool.get(name);
 | 
			
		||||
                if (win && !win.isDestroyed()) win.hide();
 | 
			
		||||
            });
 | 
			
		||||
            header.hide();
 | 
			
		||||
      
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
      
 | 
			
		||||
        this.lastVisibleWindows.forEach(name => {
 | 
			
		||||
            const win = windowPool.get(name);
 | 
			
		||||
            if (win && !win.isDestroyed()) {
 | 
			
		||||
                win.show();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async registerShortcuts() {
 | 
			
		||||
        if (!this.movementManager || !this.windowPool) {
 | 
			
		||||
            console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        const keybinds = await this.loadKeybinds();
 | 
			
		||||
        globalShortcut.unregisterAll();
 | 
			
		||||
        
 | 
			
		||||
        const header = this.windowPool.get('header');
 | 
			
		||||
        const mainWindow = header;
 | 
			
		||||
 | 
			
		||||
        const sendToRenderer = (channel, ...args) => {
 | 
			
		||||
            this.windowPool.forEach(win => {
 | 
			
		||||
                if (win && !win.isDestroyed()) {
 | 
			
		||||
                    try {
 | 
			
		||||
                        win.webContents.send(channel, ...args);
 | 
			
		||||
                    } catch (e) {
 | 
			
		||||
                        // Ignore errors for destroyed windows
 | 
			
		||||
                    }
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        sendToRenderer('shortcuts-updated', keybinds);
 | 
			
		||||
 | 
			
		||||
        // --- Hardcoded shortcuts ---
 | 
			
		||||
        const isMac = process.platform === 'darwin';
 | 
			
		||||
        const modifier = isMac ? 'Cmd' : 'Ctrl';
 | 
			
		||||
        
 | 
			
		||||
        // Monitor switching
 | 
			
		||||
        const displays = screen.getAllDisplays();
 | 
			
		||||
        if (displays.length > 1) {
 | 
			
		||||
            displays.forEach((display, index) => {
 | 
			
		||||
                const key = `${modifier}+Shift+${index + 1}`;
 | 
			
		||||
                globalShortcut.register(key, () => this.movementManager.moveToDisplay(display.id));
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Edge snapping
 | 
			
		||||
        const edgeDirections = [
 | 
			
		||||
            { key: `${modifier}+Shift+Left`, direction: 'left' },
 | 
			
		||||
            { key: `${modifier}+Shift+Right`, direction: 'right' },
 | 
			
		||||
        ];
 | 
			
		||||
        edgeDirections.forEach(({ key, direction }) => {
 | 
			
		||||
            globalShortcut.register(key, () => {
 | 
			
		||||
                if (header && header.isVisible()) this.movementManager.moveToEdge(direction);
 | 
			
		||||
            });
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        // --- User-configurable shortcuts ---
 | 
			
		||||
        if (header?.currentHeaderState === 'apikey') {
 | 
			
		||||
            if (keybinds.toggleVisibility) {
 | 
			
		||||
                globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility(this.windowPool));
 | 
			
		||||
            }
 | 
			
		||||
            console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.');
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        for (const action in keybinds) {
 | 
			
		||||
            const accelerator = keybinds[action];
 | 
			
		||||
            if (!accelerator) continue;
 | 
			
		||||
 | 
			
		||||
            let callback;
 | 
			
		||||
            switch(action) {
 | 
			
		||||
                case 'toggleVisibility':
 | 
			
		||||
                    callback = () => this.toggleAllWindowsVisibility(this.windowPool);
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'nextStep':
 | 
			
		||||
                    callback = () => askService.toggleAskButton();
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'scrollUp':
 | 
			
		||||
                    callback = () => {
 | 
			
		||||
                        const askWindow = this.windowPool.get('ask');
 | 
			
		||||
                        if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
 | 
			
		||||
                            askWindow.webContents.send('scroll-response-up');
 | 
			
		||||
                        }
 | 
			
		||||
                    };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'scrollDown':
 | 
			
		||||
                    callback = () => {
 | 
			
		||||
                        const askWindow = this.windowPool.get('ask');
 | 
			
		||||
                        if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
 | 
			
		||||
                            askWindow.webContents.send('scroll-response-down');
 | 
			
		||||
                        }
 | 
			
		||||
                    };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveUp':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('up'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveDown':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('down'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveLeft':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('left'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'moveRight':
 | 
			
		||||
                    callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('right'); };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'toggleClickThrough':
 | 
			
		||||
                     callback = () => {
 | 
			
		||||
                        this.mouseEventsIgnored = !this.mouseEventsIgnored;
 | 
			
		||||
                        if(mainWindow && !mainWindow.isDestroyed()){
 | 
			
		||||
                            mainWindow.setIgnoreMouseEvents(this.mouseEventsIgnored, { forward: true });
 | 
			
		||||
                            mainWindow.webContents.send('click-through-toggled', this.mouseEventsIgnored);
 | 
			
		||||
                        }
 | 
			
		||||
                     };
 | 
			
		||||
                     break;
 | 
			
		||||
                case 'manualScreenshot':
 | 
			
		||||
                    callback = () => {
 | 
			
		||||
                        if(mainWindow && !mainWindow.isDestroyed()) {
 | 
			
		||||
                             mainWindow.webContents.executeJavaScript('window.captureManualScreenshot && window.captureManualScreenshot();');
 | 
			
		||||
                        }
 | 
			
		||||
                    };
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'previousResponse':
 | 
			
		||||
                    callback = () => sendToRenderer('navigate-previous-response');
 | 
			
		||||
                    break;
 | 
			
		||||
                case 'nextResponse':
 | 
			
		||||
                    callback = () => sendToRenderer('navigate-next-response');
 | 
			
		||||
                    break;
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            if (callback) {
 | 
			
		||||
                try {
 | 
			
		||||
                    globalShortcut.register(accelerator, callback);
 | 
			
		||||
                } catch(e) {
 | 
			
		||||
                    console.error(`[Shortcuts] Failed to register shortcut for "${action}" (${accelerator}):`, e.message);
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        console.log('[Shortcuts] All shortcuts have been registered.');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    unregisterAll() {
 | 
			
		||||
        globalShortcut.unregisterAll();
 | 
			
		||||
        console.log('[Shortcuts] All shortcuts have been unregistered.');
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
module.exports = new ShortcutsService(); 
 | 
			
		||||
							
								
								
									
										359
									
								
								src/index.js
									
									
									
									
									
								
							
							
						
						@ -12,11 +12,11 @@ if (require('electron-squirrel-startup')) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
 | 
			
		||||
const { createWindows } = require('./electron/windowManager.js');
 | 
			
		||||
const ListenService = require('./features/listen/listenService');
 | 
			
		||||
const { initializeFirebase } = require('./common/services/firebaseClient');
 | 
			
		||||
const databaseInitializer = require('./common/services/databaseInitializer');
 | 
			
		||||
const authService = require('./common/services/authService');
 | 
			
		||||
const { createWindows } = require('./window/windowManager.js');
 | 
			
		||||
const listenService = require('./features/listen/listenService');
 | 
			
		||||
const { initializeFirebase } = require('./features/common/services/firebaseClient');
 | 
			
		||||
const databaseInitializer = require('./features/common/services/databaseInitializer');
 | 
			
		||||
const authService = require('./features/common/services/authService');
 | 
			
		||||
const path = require('node:path');
 | 
			
		||||
const express = require('express');
 | 
			
		||||
const fetch = require('node-fetch');
 | 
			
		||||
@ -24,27 +24,23 @@ const { autoUpdater } = require('electron-updater');
 | 
			
		||||
const { EventEmitter } = require('events');
 | 
			
		||||
const askService = require('./features/ask/askService');
 | 
			
		||||
const settingsService = require('./features/settings/settingsService');
 | 
			
		||||
const sessionRepository = require('./common/repositories/session');
 | 
			
		||||
const ModelStateService = require('./common/services/modelStateService');
 | 
			
		||||
const sqliteClient = require('./common/services/sqliteClient');
 | 
			
		||||
const sessionRepository = require('./features/common/repositories/session');
 | 
			
		||||
const modelStateService = require('./features/common/services/modelStateService');
 | 
			
		||||
const featureBridge = require('./bridge/featureBridge');
 | 
			
		||||
const windowBridge = require('./bridge/windowBridge');
 | 
			
		||||
 | 
			
		||||
// Global variables
 | 
			
		||||
const eventBridge = new EventEmitter();
 | 
			
		||||
let WEB_PORT = 3000;
 | 
			
		||||
let isShuttingDown = false; // Flag to prevent infinite shutdown loop
 | 
			
		||||
 | 
			
		||||
const listenService = new ListenService();
 | 
			
		||||
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
 | 
			
		||||
global.listenService = listenService;
 | 
			
		||||
 | 
			
		||||
//////// after_modelStateService ////////
 | 
			
		||||
const modelStateService = new ModelStateService(authService);
 | 
			
		||||
global.modelStateService = modelStateService;
 | 
			
		||||
//////// after_modelStateService ////////
 | 
			
		||||
 | 
			
		||||
// Import and initialize OllamaService
 | 
			
		||||
const ollamaService = require('./common/services/ollamaService');
 | 
			
		||||
const ollamaModelRepository = require('./common/repositories/ollamaModel');
 | 
			
		||||
const ollamaService = require('./features/common/services/ollamaService');
 | 
			
		||||
const ollamaModelRepository = require('./features/common/repositories/ollamaModel');
 | 
			
		||||
 | 
			
		||||
// Native deep link handling - cross-platform compatible
 | 
			
		||||
let pendingDeepLinkUrl = null;
 | 
			
		||||
@ -123,7 +119,7 @@ function setupProtocolHandling() {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function focusMainWindow() {
 | 
			
		||||
    const { windowPool } = require('./electron/windowManager');
 | 
			
		||||
    const { windowPool } = require('./window/windowManager.js');
 | 
			
		||||
    if (windowPool) {
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header && !header.isDestroyed()) {
 | 
			
		||||
@ -202,12 +198,9 @@ app.whenReady().then(async () => {
 | 
			
		||||
        await modelStateService.initialize();
 | 
			
		||||
        //////// after_modelStateService ////////
 | 
			
		||||
 | 
			
		||||
        listenService.setupIpcHandlers();
 | 
			
		||||
        askService.initialize();
 | 
			
		||||
        settingsService.initialize();
 | 
			
		||||
        setupGeneralIpcHandlers();
 | 
			
		||||
        setupOllamaIpcHandlers();
 | 
			
		||||
        setupWhisperIpcHandlers();
 | 
			
		||||
        featureBridge.initialize();  // 추가: featureBridge 초기화
 | 
			
		||||
        windowBridge.initialize();
 | 
			
		||||
        setupWebDataHandlers();
 | 
			
		||||
 | 
			
		||||
        // Initialize Ollama models in database
 | 
			
		||||
        await ollamaModelRepository.initializeDefaultModels();
 | 
			
		||||
@ -248,13 +241,6 @@ app.whenReady().then(async () => {
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on('window-all-closed', () => {
 | 
			
		||||
    listenService.stopMacOSAudioCapture();
 | 
			
		||||
    if (process.platform !== 'darwin') {
 | 
			
		||||
        app.quit();
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
app.on('before-quit', async (event) => {
 | 
			
		||||
    // Prevent infinite loop by checking if shutdown is already in progress
 | 
			
		||||
    if (isShuttingDown) {
 | 
			
		||||
@ -272,7 +258,7 @@ app.on('before-quit', async (event) => {
 | 
			
		||||
    
 | 
			
		||||
    try {
 | 
			
		||||
        // 1. Stop audio capture first (immediate)
 | 
			
		||||
        listenService.stopMacOSAudioCapture();
 | 
			
		||||
        await listenService.closeSession();
 | 
			
		||||
        console.log('[Shutdown] Audio capture stopped');
 | 
			
		||||
        
 | 
			
		||||
        // 2. End all active sessions (database operations) - with error handling
 | 
			
		||||
@ -328,309 +314,13 @@ app.on('activate', () => {
 | 
			
		||||
    }
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
function setupWhisperIpcHandlers() {
 | 
			
		||||
    const { WhisperService } = require('./common/services/whisperService');
 | 
			
		||||
    const whisperService = new WhisperService();
 | 
			
		||||
    
 | 
			
		||||
    // Forward download progress events to renderer
 | 
			
		||||
    whisperService.on('downloadProgress', (data) => {
 | 
			
		||||
        const windows = BrowserWindow.getAllWindows();
 | 
			
		||||
        windows.forEach(window => {
 | 
			
		||||
            window.webContents.send('whisper:download-progress', data);
 | 
			
		||||
        });
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    // IPC handlers for Whisper operations
 | 
			
		||||
    ipcMain.handle('whisper:download-model', async (event, modelId) => {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[Whisper IPC] Starting download for model: ${modelId}`);
 | 
			
		||||
            
 | 
			
		||||
            // Ensure WhisperService is initialized first
 | 
			
		||||
            if (!whisperService.isInitialized) {
 | 
			
		||||
                console.log('[Whisper IPC] Initializing WhisperService...');
 | 
			
		||||
                await whisperService.initialize();
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Set up progress listener
 | 
			
		||||
            const progressHandler = (data) => {
 | 
			
		||||
                if (data.modelId === modelId) {
 | 
			
		||||
                    event.sender.send('whisper:download-progress', data);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            whisperService.on('downloadProgress', progressHandler);
 | 
			
		||||
            
 | 
			
		||||
            try {
 | 
			
		||||
                await whisperService.ensureModelAvailable(modelId);
 | 
			
		||||
                console.log(`[Whisper IPC] Model ${modelId} download completed successfully`);
 | 
			
		||||
            } finally {
 | 
			
		||||
                // Cleanup listener
 | 
			
		||||
                whisperService.removeListener('downloadProgress', progressHandler);
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error(`[Whisper IPC] Failed to download model ${modelId}:`, error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
    
 | 
			
		||||
    ipcMain.handle('whisper:get-installed-models', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            // Ensure WhisperService is initialized first
 | 
			
		||||
            if (!whisperService.isInitialized) {
 | 
			
		||||
                console.log('[Whisper IPC] Initializing WhisperService for model list...');
 | 
			
		||||
                await whisperService.initialize();
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            const models = await whisperService.getInstalledModels();
 | 
			
		||||
            return { success: true, models };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Whisper IPC] Failed to get installed models:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupGeneralIpcHandlers() {
 | 
			
		||||
    const userRepository = require('./common/repositories/user');
 | 
			
		||||
    const presetRepository = require('./common/repositories/preset');
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('get-user-presets', () => {
 | 
			
		||||
        // The adapter injects the UID.
 | 
			
		||||
        return presetRepository.getPresets();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('get-preset-templates', () => {
 | 
			
		||||
        return presetRepository.getPresetTemplates();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('start-firebase-auth', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const authUrl = `http://localhost:${WEB_PORT}/login?mode=electron`;
 | 
			
		||||
            console.log(`[Auth] Opening Firebase auth URL in browser: ${authUrl}`);
 | 
			
		||||
            await shell.openExternal(authUrl);
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Auth] Failed to open Firebase auth URL:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('get-web-url', () => {
 | 
			
		||||
        return process.env.pickleglass_WEB_URL || 'http://localhost:3000';
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('get-current-user', () => {
 | 
			
		||||
        return authService.getCurrentUser();
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // --- Web UI Data Handlers (New) ---
 | 
			
		||||
    setupWebDataHandlers();
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupOllamaIpcHandlers() {
 | 
			
		||||
    // Ollama status and installation
 | 
			
		||||
    ipcMain.handle('ollama:get-status', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const installed = await ollamaService.isInstalled();
 | 
			
		||||
            const running = installed ? await ollamaService.isServiceRunning() : false;
 | 
			
		||||
            const models = await ollamaService.getAllModelsWithStatus();
 | 
			
		||||
            
 | 
			
		||||
            return { 
 | 
			
		||||
                installed, 
 | 
			
		||||
                running, 
 | 
			
		||||
                models,
 | 
			
		||||
                success: true 
 | 
			
		||||
            };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to get status:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('ollama:install', async (event) => {
 | 
			
		||||
        try {
 | 
			
		||||
            const onProgress = (data) => {
 | 
			
		||||
                event.sender.send('ollama:install-progress', data);
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await ollamaService.autoInstall(onProgress);
 | 
			
		||||
            
 | 
			
		||||
            if (!await ollamaService.isServiceRunning()) {
 | 
			
		||||
                onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 });
 | 
			
		||||
                await ollamaService.startService();
 | 
			
		||||
                onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 });
 | 
			
		||||
            }
 | 
			
		||||
            event.sender.send('ollama:install-complete', { success: true });
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to install:', error);
 | 
			
		||||
            event.sender.send('ollama:install-complete', { success: false, error: error.message });
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    ipcMain.handle('ollama:start-service', async (event) => {
 | 
			
		||||
        try {
 | 
			
		||||
            if (!await ollamaService.isServiceRunning()) {
 | 
			
		||||
                console.log('[Ollama IPC] Starting Ollama service...');
 | 
			
		||||
                await ollamaService.startService();
 | 
			
		||||
            }
 | 
			
		||||
            event.sender.send('ollama:install-complete', { success: true });
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to start service:', error);
 | 
			
		||||
            event.sender.send('ollama:install-complete', { success: false, error: error.message });
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Ensure Ollama is ready (starts service if installed but not running)
 | 
			
		||||
    ipcMain.handle('ollama:ensure-ready', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            if (await ollamaService.isInstalled() && !await ollamaService.isServiceRunning()) {
 | 
			
		||||
                console.log('[Ollama IPC] Ollama installed but not running, starting service...');
 | 
			
		||||
                await ollamaService.startService();
 | 
			
		||||
            }
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to ensure ready:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Get all models with their status
 | 
			
		||||
    ipcMain.handle('ollama:get-models', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const models = await ollamaService.getAllModelsWithStatus();
 | 
			
		||||
            return { success: true, models };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to get models:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Get model suggestions for autocomplete
 | 
			
		||||
    ipcMain.handle('ollama:get-model-suggestions', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const suggestions = await ollamaService.getModelSuggestions();
 | 
			
		||||
            return { success: true, suggestions };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to get model suggestions:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Pull/install a specific model
 | 
			
		||||
    ipcMain.handle('ollama:pull-model', async (event, modelName) => {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[Ollama IPC] Starting model pull: ${modelName}`);
 | 
			
		||||
            
 | 
			
		||||
            // Update DB status to installing
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, false, true);
 | 
			
		||||
            
 | 
			
		||||
            // Set up progress listener for real-time updates
 | 
			
		||||
            const progressHandler = (data) => {
 | 
			
		||||
                if (data.model === modelName) {
 | 
			
		||||
                    event.sender.send('ollama:pull-progress', data);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            const completeHandler = (data) => {
 | 
			
		||||
                if (data.model === modelName) {
 | 
			
		||||
                    console.log(`[Ollama IPC] Model ${modelName} pull completed`);
 | 
			
		||||
                    // Clean up listeners
 | 
			
		||||
                    ollamaService.removeListener('pull-progress', progressHandler);
 | 
			
		||||
                    ollamaService.removeListener('pull-complete', completeHandler);
 | 
			
		||||
                }
 | 
			
		||||
            };
 | 
			
		||||
            
 | 
			
		||||
            ollamaService.on('pull-progress', progressHandler);
 | 
			
		||||
            ollamaService.on('pull-complete', completeHandler);
 | 
			
		||||
            
 | 
			
		||||
            // Pull the model using REST API
 | 
			
		||||
            await ollamaService.pullModel(modelName);
 | 
			
		||||
            
 | 
			
		||||
            // Update DB status to installed
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, true, false);
 | 
			
		||||
            
 | 
			
		||||
            console.log(`[Ollama IPC] Model ${modelName} pull successful`);
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to pull model:', error);
 | 
			
		||||
            // Reset status on error
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, false, false);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Check if a specific model is installed
 | 
			
		||||
    ipcMain.handle('ollama:is-model-installed', async (event, modelName) => {
 | 
			
		||||
        try {
 | 
			
		||||
            const installed = await ollamaService.isModelInstalled(modelName);
 | 
			
		||||
            return { success: true, installed };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to check model installation:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Warm up a specific model
 | 
			
		||||
    ipcMain.handle('ollama:warm-up-model', async (event, modelName) => {
 | 
			
		||||
        try {
 | 
			
		||||
            const success = await ollamaService.warmUpModel(modelName);
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to warm up model:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Auto warm-up currently selected model
 | 
			
		||||
    ipcMain.handle('ollama:auto-warm-up', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const success = await ollamaService.autoWarmUpSelectedModel();
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to auto warm-up:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Get warm-up status for debugging
 | 
			
		||||
    ipcMain.handle('ollama:get-warm-up-status', async () => {
 | 
			
		||||
        try {
 | 
			
		||||
            const status = ollamaService.getWarmUpStatus();
 | 
			
		||||
            return { success: true, status };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to get warm-up status:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    // Shutdown Ollama service manually
 | 
			
		||||
    ipcMain.handle('ollama:shutdown', async (event, force = false) => {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[Ollama IPC] Manual shutdown requested (force: ${force})`);
 | 
			
		||||
            const success = await ollamaService.shutdown(force);
 | 
			
		||||
            return { success };
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[Ollama IPC] Failed to shutdown Ollama:', error);
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
    console.log('[Ollama IPC] Handlers registered');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function setupWebDataHandlers() {
 | 
			
		||||
    const sessionRepository = require('./common/repositories/session');
 | 
			
		||||
    const sessionRepository = require('./features/common/repositories/session');
 | 
			
		||||
    const sttRepository = require('./features/listen/stt/repositories');
 | 
			
		||||
    const summaryRepository = require('./features/listen/summary/repositories');
 | 
			
		||||
    const askRepository = require('./features/ask/repositories');
 | 
			
		||||
    const userRepository = require('./common/repositories/user');
 | 
			
		||||
    const presetRepository = require('./common/repositories/preset');
 | 
			
		||||
    const userRepository = require('./features/common/repositories/user');
 | 
			
		||||
    const presetRepository = require('./features/common/repositories/preset');
 | 
			
		||||
 | 
			
		||||
    const handleRequest = async (channel, responseChannel, payload) => {
 | 
			
		||||
        let result;
 | 
			
		||||
@ -788,7 +478,7 @@ async function handleCustomUrl(url) {
 | 
			
		||||
                handlePersonalizeFromUrl(params);
 | 
			
		||||
                break;
 | 
			
		||||
            default:
 | 
			
		||||
                const { windowPool } = require('./electron/windowManager');
 | 
			
		||||
                const { windowPool } = require('./window/windowManager.js');
 | 
			
		||||
                const header = windowPool.get('header');
 | 
			
		||||
                if (header) {
 | 
			
		||||
                    if (header.isMinimized()) header.restore();
 | 
			
		||||
@ -806,7 +496,7 @@ async function handleCustomUrl(url) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function handleFirebaseAuthCallback(params) {
 | 
			
		||||
    const userRepository = require('./common/repositories/user');
 | 
			
		||||
    const userRepository = require('./features/common/repositories/user');
 | 
			
		||||
    const { token: idToken } = params;
 | 
			
		||||
 | 
			
		||||
    if (!idToken) {
 | 
			
		||||
@ -842,6 +532,7 @@ async function handleFirebaseAuthCallback(params) {
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        // 1. Sync user data to local DB
 | 
			
		||||
        userRepository.setAuthService(authService);
 | 
			
		||||
        userRepository.findOrCreate(firebaseUser);
 | 
			
		||||
        console.log('[Auth] User data synced with local DB.');
 | 
			
		||||
 | 
			
		||||
@ -850,7 +541,7 @@ async function handleFirebaseAuthCallback(params) {
 | 
			
		||||
        console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...');
 | 
			
		||||
 | 
			
		||||
        // 3. Focus the app window
 | 
			
		||||
        const { windowPool } = require('./electron/windowManager');
 | 
			
		||||
        const { windowPool } = require('./window/windowManager.js');
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header) {
 | 
			
		||||
            if (header.isMinimized()) header.restore();
 | 
			
		||||
@ -863,7 +554,7 @@ async function handleFirebaseAuthCallback(params) {
 | 
			
		||||
        console.error('[Auth] Error during custom token exchange or sign-in:', error);
 | 
			
		||||
        // The UI will not change, and the user can try again.
 | 
			
		||||
        // Optionally, send a generic error event to the renderer.
 | 
			
		||||
        const { windowPool } = require('./electron/windowManager');
 | 
			
		||||
        const { windowPool } = require('./window/windowManager.js');
 | 
			
		||||
        const header = windowPool.get('header');
 | 
			
		||||
        if (header) {
 | 
			
		||||
            header.webContents.send('auth-failed', { message: error.message });
 | 
			
		||||
@ -874,7 +565,7 @@ async function handleFirebaseAuthCallback(params) {
 | 
			
		||||
function handlePersonalizeFromUrl(params) {
 | 
			
		||||
    console.log('[Custom URL] Personalize params:', params);
 | 
			
		||||
    
 | 
			
		||||
    const { windowPool } = require('./electron/windowManager');
 | 
			
		||||
    const { windowPool } = require('./window/windowManager.js');
 | 
			
		||||
    const header = windowPool.get('header');
 | 
			
		||||
    
 | 
			
		||||
    if (header) {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										298
									
								
								src/preload.js
									
									
									
									
									
								
							
							
						
						@ -1,2 +1,296 @@
 | 
			
		||||
// See the Electron documentation for details on how to use preload scripts:
 | 
			
		||||
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
 | 
			
		||||
// src/preload.js
 | 
			
		||||
const { contextBridge, ipcRenderer } = require('electron');
 | 
			
		||||
 | 
			
		||||
contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
  // Platform information for renderer processes
 | 
			
		||||
  platform: {
 | 
			
		||||
    isLinux: process.platform === 'linux',
 | 
			
		||||
    isMacOS: process.platform === 'darwin',
 | 
			
		||||
    isWindows: process.platform === 'win32',
 | 
			
		||||
    platform: process.platform
 | 
			
		||||
  },
 | 
			
		||||
  
 | 
			
		||||
  // Common utilities used across multiple components
 | 
			
		||||
  common: {
 | 
			
		||||
    // User & Auth
 | 
			
		||||
    getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
 | 
			
		||||
    startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
 | 
			
		||||
    firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
 | 
			
		||||
    
 | 
			
		||||
    // App Control
 | 
			
		||||
      quitApplication: () => ipcRenderer.invoke('quit-application'),
 | 
			
		||||
 | 
			
		||||
    // User state listener (used by multiple components)
 | 
			
		||||
      onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
 | 
			
		||||
      removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // UI Component specific namespaces
 | 
			
		||||
  // src/ui/app/ApiKeyHeader.js
 | 
			
		||||
  apiKeyHeader: {
 | 
			
		||||
    // Model & Provider Management
 | 
			
		||||
    getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
 | 
			
		||||
    getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
 | 
			
		||||
    getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
 | 
			
		||||
    ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
 | 
			
		||||
    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),
 | 
			
		||||
    setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
 | 
			
		||||
    areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'),
 | 
			
		||||
    
 | 
			
		||||
    // Window Management
 | 
			
		||||
    getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
 | 
			
		||||
    moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback),
 | 
			
		||||
    removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback),
 | 
			
		||||
    onceOllamaInstallComplete: (callback) => ipcRenderer.once('ollama:install-complete', callback),
 | 
			
		||||
    removeOnceOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback),
 | 
			
		||||
    onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
 | 
			
		||||
    removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback),
 | 
			
		||||
    onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
 | 
			
		||||
    removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
 | 
			
		||||
 | 
			
		||||
    // Remove all listeners (for cleanup)
 | 
			
		||||
    removeAllListeners: () => {
 | 
			
		||||
      ipcRenderer.removeAllListeners('whisper:download-progress');
 | 
			
		||||
      ipcRenderer.removeAllListeners('ollama:install-progress');
 | 
			
		||||
      ipcRenderer.removeAllListeners('ollama:pull-progress');
 | 
			
		||||
      ipcRenderer.removeAllListeners('ollama:install-complete');
 | 
			
		||||
    }
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/HeaderController.js
 | 
			
		||||
  headerController: {
 | 
			
		||||
    // State Management
 | 
			
		||||
    sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
 | 
			
		||||
    
 | 
			
		||||
    // Window Management
 | 
			
		||||
    resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
 | 
			
		||||
    
 | 
			
		||||
    // Permissions
 | 
			
		||||
    checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
 | 
			
		||||
    checkPermissionsCompleted: () => ipcRenderer.invoke('check-permissions-completed'),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
 | 
			
		||||
    removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
 | 
			
		||||
    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)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/MainHeader.js
 | 
			
		||||
  mainHeader: {
 | 
			
		||||
    // Window Management
 | 
			
		||||
    getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
 | 
			
		||||
    moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
 | 
			
		||||
    sendHeaderAnimationFinished: (state) => ipcRenderer.send('header-animation-finished', state),
 | 
			
		||||
 | 
			
		||||
    // Settings Window Management
 | 
			
		||||
    cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-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),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback),
 | 
			
		||||
    removeOnListenChangeSessionResult: (callback) => ipcRenderer.removeListener('listen:changeSessionResult', callback),
 | 
			
		||||
    onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
 | 
			
		||||
    removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/PermissionHeader.js
 | 
			
		||||
  permissionHeader: {
 | 
			
		||||
    // Permission Management
 | 
			
		||||
    checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
 | 
			
		||||
    requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
 | 
			
		||||
    openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
 | 
			
		||||
    markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed')
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/app/PickleGlassApp.js
 | 
			
		||||
  pickleGlassApp: {
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onClickThroughToggled: (callback) => ipcRenderer.on('click-through-toggled', callback),
 | 
			
		||||
    removeOnClickThroughToggled: (callback) => ipcRenderer.removeListener('click-through-toggled', callback),
 | 
			
		||||
    removeAllClickThroughListeners: () => ipcRenderer.removeAllListeners('click-through-toggled')
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/ask/AskView.js
 | 
			
		||||
  askView: {
 | 
			
		||||
    // Window Management
 | 
			
		||||
    closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
 | 
			
		||||
    adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
 | 
			
		||||
    
 | 
			
		||||
    // Message Handling
 | 
			
		||||
    sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
 | 
			
		||||
 | 
			
		||||
    // Listeners
 | 
			
		||||
    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),
 | 
			
		||||
    
 | 
			
		||||
    onScrollResponseUp: (callback) => ipcRenderer.on('aks:scrollResponseUp', callback),
 | 
			
		||||
    removeOnScrollResponseUp: (callback) => ipcRenderer.removeListener('aks:scrollResponseUp', callback),
 | 
			
		||||
    onScrollResponseDown: (callback) => ipcRenderer.on('aks:scrollResponseDown', callback),
 | 
			
		||||
    removeOnScrollResponseDown: (callback) => ipcRenderer.removeListener('aks:scrollResponseDown', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/listen/ListenView.js
 | 
			
		||||
  listenView: {
 | 
			
		||||
    // Window Management
 | 
			
		||||
    adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
 | 
			
		||||
    removeOnSessionStateChanged: (callback) => ipcRenderer.removeListener('session-state-changed', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/listen/stt/SttView.js
 | 
			
		||||
  sttView: {
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onSttUpdate: (callback) => ipcRenderer.on('stt-update', callback),
 | 
			
		||||
    removeOnSttUpdate: (callback) => ipcRenderer.removeListener('stt-update', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/listen/summary/SummaryView.js
 | 
			
		||||
  summaryView: {
 | 
			
		||||
    // Message Handling
 | 
			
		||||
    sendQuestionFromSummary: (text) => ipcRenderer.invoke('ask:sendQuestionFromSummary', text),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback),
 | 
			
		||||
    removeOnSummaryUpdate: (callback) => ipcRenderer.removeListener('summary-update', callback),
 | 
			
		||||
    removeAllSummaryUpdateListeners: () => ipcRenderer.removeAllListeners('summary-update')
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/settings/SettingsView.js
 | 
			
		||||
  settingsView: {
 | 
			
		||||
    // User & Auth
 | 
			
		||||
    getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
 | 
			
		||||
    openPersonalizePage: () => ipcRenderer.invoke('open-personalize-page'),
 | 
			
		||||
    firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
 | 
			
		||||
    startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
 | 
			
		||||
 | 
			
		||||
    // Model & Provider Management
 | 
			
		||||
    getModelSettings: () => ipcRenderer.invoke('settings:get-model-settings'), // Facade call
 | 
			
		||||
    getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
 | 
			
		||||
    getAllKeys: () => ipcRenderer.invoke('model:get-all-keys'),
 | 
			
		||||
    getAvailableModels: (type) => ipcRenderer.invoke('model:get-available-models', type),
 | 
			
		||||
    getSelectedModels: () => ipcRenderer.invoke('model:get-selected-models'),
 | 
			
		||||
    validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
 | 
			
		||||
    saveApiKey: (key) => ipcRenderer.invoke('model:save-api-key', key),
 | 
			
		||||
    removeApiKey: (provider) => ipcRenderer.invoke('model:remove-api-key', provider),
 | 
			
		||||
    setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
 | 
			
		||||
    
 | 
			
		||||
    // Ollama Management
 | 
			
		||||
    getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
 | 
			
		||||
    ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
 | 
			
		||||
    shutdownOllama: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
 | 
			
		||||
    
 | 
			
		||||
    // Whisper Management
 | 
			
		||||
    getWhisperInstalledModels: () => ipcRenderer.invoke('whisper:get-installed-models'),
 | 
			
		||||
    downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
 | 
			
		||||
    
 | 
			
		||||
    // Settings Management
 | 
			
		||||
    getPresets: () => ipcRenderer.invoke('settings:getPresets'),
 | 
			
		||||
    getAutoUpdate: () => ipcRenderer.invoke('settings:get-auto-update'),
 | 
			
		||||
    setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
 | 
			
		||||
    getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
 | 
			
		||||
    toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
 | 
			
		||||
    getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'),
 | 
			
		||||
    openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'),
 | 
			
		||||
    
 | 
			
		||||
    // Window Management
 | 
			
		||||
    moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
 | 
			
		||||
    cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
 | 
			
		||||
    hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
 | 
			
		||||
    
 | 
			
		||||
    // App Control
 | 
			
		||||
    quitApplication: () => ipcRenderer.invoke('quit-application'),
 | 
			
		||||
    
 | 
			
		||||
    // Progress Tracking
 | 
			
		||||
    pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
 | 
			
		||||
    removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
 | 
			
		||||
    onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback),
 | 
			
		||||
    removeOnSettingsUpdated: (callback) => ipcRenderer.removeListener('settings-updated', callback),
 | 
			
		||||
    onPresetsUpdated: (callback) => ipcRenderer.on('presets-updated', callback),
 | 
			
		||||
    removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
 | 
			
		||||
    onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
 | 
			
		||||
    removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
 | 
			
		||||
    onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
 | 
			
		||||
    removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
 | 
			
		||||
    onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
 | 
			
		||||
    removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/settings/ShortCutSettingsView.js
 | 
			
		||||
  shortcutSettingsView: {
 | 
			
		||||
    // Shortcut Management
 | 
			
		||||
    saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts),
 | 
			
		||||
    getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'),
 | 
			
		||||
    closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    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),
 | 
			
		||||
    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
 | 
			
		||||
  listenCapture: {
 | 
			
		||||
    // Audio Management
 | 
			
		||||
    sendMicAudioContent: (data) => ipcRenderer.invoke('listen:sendMicAudio', data),
 | 
			
		||||
    sendSystemAudioContent: (data) => ipcRenderer.invoke('listen:sendSystemAudio', data),
 | 
			
		||||
    startMacosSystemAudio: () => ipcRenderer.invoke('listen:startMacosSystemAudio'),
 | 
			
		||||
    stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
 | 
			
		||||
    
 | 
			
		||||
    // Session Management
 | 
			
		||||
    isSessionActive: () => ipcRenderer.invoke('is-session-active'),
 | 
			
		||||
    
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
 | 
			
		||||
    removeOnSystemAudioData: (callback) => ipcRenderer.removeListener('system-audio-data', callback)
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
  // src/ui/listen/audioCore/renderer.js
 | 
			
		||||
  renderer: {
 | 
			
		||||
    // Listeners
 | 
			
		||||
    onChangeListenCaptureState: (callback) => ipcRenderer.on('change-listen-capture-state', callback),
 | 
			
		||||
    removeOnChangeListenCaptureState: (callback) => ipcRenderer.removeListener('change-listen-capture-state', callback)
 | 
			
		||||
  }
 | 
			
		||||
});
 | 
			
		||||
							
								
								
									
										2081
									
								
								src/ui/app/ApiKeyHeader.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -1,18 +1,20 @@
 | 
			
		||||
import './MainHeader.js';
 | 
			
		||||
import './ApiKeyHeader.js';
 | 
			
		||||
import './PermissionHeader.js';
 | 
			
		||||
import './WelcomeHeader.js';
 | 
			
		||||
 | 
			
		||||
class HeaderTransitionManager {
 | 
			
		||||
    constructor() {
 | 
			
		||||
        this.headerContainer      = document.getElementById('header-container');
 | 
			
		||||
        this.currentHeaderType    = null;   // 'apikey' | 'main' | 'permission'
 | 
			
		||||
        this.currentHeaderType    = null;   // 'welcome' | 'apikey' | 'main' | 'permission'
 | 
			
		||||
        this.welcomeHeader        = null;
 | 
			
		||||
        this.apiKeyHeader         = null;
 | 
			
		||||
        this.mainHeader            = null;
 | 
			
		||||
        this.permissionHeader      = null;
 | 
			
		||||
 | 
			
		||||
        /**
 | 
			
		||||
         * only one header window is allowed
 | 
			
		||||
         * @param {'apikey'|'main'|'permission'} type
 | 
			
		||||
         * @param {'welcome'|'apikey'|'main'|'permission'} type
 | 
			
		||||
         */
 | 
			
		||||
        this.ensureHeader = (type) => {
 | 
			
		||||
            console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
 | 
			
		||||
@ -23,15 +25,27 @@ class HeaderTransitionManager {
 | 
			
		||||
 | 
			
		||||
            this.headerContainer.innerHTML = '';
 | 
			
		||||
            
 | 
			
		||||
            this.welcomeHeader = null;
 | 
			
		||||
            this.apiKeyHeader = null;
 | 
			
		||||
            this.mainHeader = null;
 | 
			
		||||
            this.permissionHeader = null;
 | 
			
		||||
 | 
			
		||||
            // Create new header element
 | 
			
		||||
            if (type === 'apikey') {
 | 
			
		||||
            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') {
 | 
			
		||||
                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.continueCallback = () => this.transitionToMainHeader();
 | 
			
		||||
@ -48,56 +62,63 @@ class HeaderTransitionManager {
 | 
			
		||||
 | 
			
		||||
        console.log('[HeaderController] Manager initialized');
 | 
			
		||||
 | 
			
		||||
        // WelcomeHeader 콜백 메서드들
 | 
			
		||||
        this.handleLoginOption = this.handleLoginOption.bind(this);
 | 
			
		||||
        this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
 | 
			
		||||
 | 
			
		||||
        this._bootstrap();
 | 
			
		||||
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
 | 
			
		||||
            ipcRenderer.on('user-state-changed', (event, userState) => {
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.headerController.onUserStateChanged((event, userState) => {
 | 
			
		||||
                console.log('[HeaderController] Received user state change:', userState);
 | 
			
		||||
                this.handleStateUpdate(userState);
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            ipcRenderer.on('auth-failed', (event, { message }) => {
 | 
			
		||||
            window.api.headerController.onAuthFailed((event, { message }) => {
 | 
			
		||||
                console.error('[HeaderController] Received auth failure from main process:', message);
 | 
			
		||||
                if (this.apiKeyHeader) {
 | 
			
		||||
                    this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.';
 | 
			
		||||
                    this.apiKeyHeader.isLoading = false;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
            ipcRenderer.on('force-show-apikey-header', async () => {
 | 
			
		||||
            window.api.headerController.onForceShowApiKeyHeader(async () => {
 | 
			
		||||
                console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
 | 
			
		||||
                await this._resizeForApiKey();
 | 
			
		||||
                this.ensureHeader('apikey');
 | 
			
		||||
            });
 | 
			
		||||
                const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
 | 
			
		||||
                if (!isConfigured) {
 | 
			
		||||
                    await this._resizeForWelcome();
 | 
			
		||||
                    this.ensureHeader('welcome');
 | 
			
		||||
                } else {
 | 
			
		||||
                    await this._resizeForApiKey();
 | 
			
		||||
                    this.ensureHeader('apikey');
 | 
			
		||||
                }
 | 
			
		||||
            });            
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    notifyHeaderState(stateOverride) {
 | 
			
		||||
        const state = stateOverride || this.currentHeaderType || 'apikey';
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            window.require('electron').ipcRenderer.send('header-state-changed', state);
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.headerController.sendHeaderStateChanged(state);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _bootstrap() {
 | 
			
		||||
        // The initial state will be sent by the main process via 'user-state-changed'
 | 
			
		||||
        // We just need to request it.
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const userState = await window.require('electron').ipcRenderer.invoke('get-current-user');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            const userState = await window.api.common.getCurrentUser();
 | 
			
		||||
            console.log('[HeaderController] Bootstrapping with initial user state:', userState);
 | 
			
		||||
            this.handleStateUpdate(userState);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Fallback for non-electron environment (testing/web)
 | 
			
		||||
            this.ensureHeader('apikey');
 | 
			
		||||
            this.ensureHeader('welcome');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    //////// after_modelStateService ////////
 | 
			
		||||
    async handleStateUpdate(userState) {
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        const isConfigured = await ipcRenderer.invoke('model:are-providers-configured');
 | 
			
		||||
        const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
 | 
			
		||||
 | 
			
		||||
        if (isConfigured) {
 | 
			
		||||
            const { isLoggedIn } = userState;
 | 
			
		||||
@ -112,10 +133,38 @@ class HeaderTransitionManager {
 | 
			
		||||
                this.transitionToMainHeader();
 | 
			
		||||
            }
 | 
			
		||||
        } else {
 | 
			
		||||
            await this._resizeForApiKey();
 | 
			
		||||
            this.ensureHeader('apikey');
 | 
			
		||||
            // 프로바이더가 설정되지 않았으면 WelcomeHeader 먼저 표시
 | 
			
		||||
            await this._resizeForWelcome();
 | 
			
		||||
            this.ensureHeader('welcome');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // 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() {
 | 
			
		||||
@ -126,10 +175,9 @@ class HeaderTransitionManager {
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Check if permissions were previously completed
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            try {
 | 
			
		||||
                const permissionsCompleted = await ipcRenderer.invoke('check-permissions-completed');
 | 
			
		||||
                const permissionsCompleted = await window.api.headerController.checkPermissionsCompleted();
 | 
			
		||||
                if (permissionsCompleted) {
 | 
			
		||||
                    console.log('[HeaderController] Permissions were previously completed, checking current status...');
 | 
			
		||||
                    
 | 
			
		||||
@ -161,39 +209,38 @@ class HeaderTransitionManager {
 | 
			
		||||
        this.ensureHeader('main');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _resizeForMain() {
 | 
			
		||||
        if (!window.require) return;
 | 
			
		||||
        return window
 | 
			
		||||
            .require('electron')
 | 
			
		||||
            .ipcRenderer.invoke('resize-header-window', { width: 353, height: 47 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
    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() {
 | 
			
		||||
        if (!window.require) return;
 | 
			
		||||
        return window
 | 
			
		||||
            .require('electron')
 | 
			
		||||
            .ipcRenderer.invoke('resize-header-window', { width: 350, height: 300 })
 | 
			
		||||
            .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() {
 | 
			
		||||
        if (!window.require) return;
 | 
			
		||||
        return window
 | 
			
		||||
            .require('electron')
 | 
			
		||||
            .ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 })
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForWelcome() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        console.log('[HeaderController] _resizeForWelcome: Resizing window to 456x370');
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 456, height: 364 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async checkPermissions() {
 | 
			
		||||
        if (!window.require) {
 | 
			
		||||
        if (!window.api) {
 | 
			
		||||
            return { success: true };
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const permissions = await ipcRenderer.invoke('check-system-permissions');
 | 
			
		||||
            const permissions = await window.api.headerController.checkSystemPermissions();
 | 
			
		||||
            console.log('[HeaderController] Current permissions:', permissions);
 | 
			
		||||
            
 | 
			
		||||
            if (!permissions.needsSetup) {
 | 
			
		||||
@ -4,8 +4,8 @@ export class MainHeader extends LitElement {
 | 
			
		||||
    static properties = {
 | 
			
		||||
        // isSessionActive: { type: Boolean, state: true },
 | 
			
		||||
        isTogglingSession: { type: Boolean, state: true },
 | 
			
		||||
        actionText: { type: String, state: true },
 | 
			
		||||
        shortcuts: { type: Object, state: true },
 | 
			
		||||
        listenSessionStatus: { type: String, state: true },
 | 
			
		||||
    };
 | 
			
		||||
 | 
			
		||||
    static styles = css`
 | 
			
		||||
@ -348,9 +348,8 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        this.isAnimating = false;
 | 
			
		||||
        this.hasSlidIn = false;
 | 
			
		||||
        this.settingsHideTimer = null;
 | 
			
		||||
        // this.isSessionActive = false;
 | 
			
		||||
        this.isTogglingSession = false;
 | 
			
		||||
        this.actionText = 'Listen';
 | 
			
		||||
        this.listenSessionStatus = 'beforeSession';
 | 
			
		||||
        this.animationEndTimer = null;
 | 
			
		||||
        this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
 | 
			
		||||
        this.handleMouseMove = this.handleMouseMove.bind(this);
 | 
			
		||||
@ -359,11 +358,19 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        this.wasJustDragged = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _getListenButtonText(status) {
 | 
			
		||||
        switch (status) {
 | 
			
		||||
            case 'beforeSession': return 'Listen';
 | 
			
		||||
            case 'inSession'   : return 'Stop';
 | 
			
		||||
            case 'afterSession': return 'Done';
 | 
			
		||||
            default            : return 'Listen';
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleMouseDown(e) {
 | 
			
		||||
        e.preventDefault();
 | 
			
		||||
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        const initialPosition = await ipcRenderer.invoke('get-header-position');
 | 
			
		||||
        const initialPosition = await window.api.mainHeader.getHeaderPosition();
 | 
			
		||||
 | 
			
		||||
        this.dragState = {
 | 
			
		||||
            initialMouseX: e.screenX,
 | 
			
		||||
@ -390,8 +397,7 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
 | 
			
		||||
        const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
 | 
			
		||||
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        ipcRenderer.invoke('move-header-to', newWindowX, newWindowY);
 | 
			
		||||
        window.api.mainHeader.moveHeaderTo(newWindowX, newWindowY);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleMouseUp(e) {
 | 
			
		||||
@ -447,12 +453,12 @@ export class MainHeader extends LitElement {
 | 
			
		||||
    
 | 
			
		||||
        if (this.classList.contains('hiding')) {
 | 
			
		||||
            this.classList.add('hidden');
 | 
			
		||||
            if (window.require) {
 | 
			
		||||
                window.require('electron').ipcRenderer.send('header-animation-finished', 'hidden');
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                window.api.mainHeader.sendHeaderAnimationFinished('hidden');
 | 
			
		||||
            }
 | 
			
		||||
        } else if (this.classList.contains('showing')) {
 | 
			
		||||
            if (window.require) {
 | 
			
		||||
                window.require('electron').ipcRenderer.send('header-animation-finished', 'visible');
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                window.api.mainHeader.sendHeaderAnimationFinished('visible');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -466,26 +472,27 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
        this.addEventListener('animationend', this.handleAnimationEnd);
 | 
			
		||||
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
 | 
			
		||||
            this._sessionStateTextListener = (event, text) => {
 | 
			
		||||
                this.actionText = text;
 | 
			
		||||
                this.isTogglingSession = false;
 | 
			
		||||
            this._sessionStateTextListener = (event, { success }) => {
 | 
			
		||||
                if (success) {
 | 
			
		||||
                    this.listenSessionStatus = ({
 | 
			
		||||
                        beforeSession: 'inSession',
 | 
			
		||||
                        inSession: 'afterSession',
 | 
			
		||||
                        afterSession: 'beforeSession',
 | 
			
		||||
                    })[this.listenSessionStatus] || 'beforeSession';
 | 
			
		||||
                } else {
 | 
			
		||||
                    this.listenSessionStatus = 'beforeSession';
 | 
			
		||||
                }
 | 
			
		||||
                this.isTogglingSession = false; // ✨ 로딩 상태만 해제
 | 
			
		||||
            };
 | 
			
		||||
            ipcRenderer.on('session-state-text', this._sessionStateTextListener);
 | 
			
		||||
            window.api.mainHeader.onListenChangeSessionResult(this._sessionStateTextListener);
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
            // this._sessionStateListener = (event, { isActive }) => {
 | 
			
		||||
            //     this.isSessionActive = isActive;
 | 
			
		||||
            //     this.isTogglingSession = false;
 | 
			
		||||
            // };
 | 
			
		||||
            // ipcRenderer.on('session-state-changed', this._sessionStateListener);
 | 
			
		||||
            this._shortcutListener = (event, keybinds) => {
 | 
			
		||||
                console.log('[MainHeader] Received updated shortcuts:', keybinds);
 | 
			
		||||
                this.shortcuts = keybinds;
 | 
			
		||||
            };
 | 
			
		||||
            ipcRenderer.on('shortcuts-updated', this._shortcutListener);
 | 
			
		||||
            window.api.mainHeader.onShortcutsUpdated(this._shortcutListener);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -498,39 +505,34 @@ export class MainHeader extends LitElement {
 | 
			
		||||
            this.animationEndTimer = null;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            if (this._sessionStateTextListener) {
 | 
			
		||||
                ipcRenderer.removeListener('session-state-text', this._sessionStateTextListener);
 | 
			
		||||
                window.api.mainHeader.removeOnListenChangeSessionResult(this._sessionStateTextListener);
 | 
			
		||||
            }
 | 
			
		||||
            // if (this._sessionStateListener) {
 | 
			
		||||
            //     ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
 | 
			
		||||
            // }
 | 
			
		||||
            if (this._shortcutListener) {
 | 
			
		||||
                ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener);
 | 
			
		||||
                window.api.mainHeader.removeOnShortcutsUpdated(this._shortcutListener);
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    invoke(channel, ...args) {
 | 
			
		||||
        if (this.wasJustDragged) return;
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            window.require('electron').ipcRenderer.invoke(channel, ...args);
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.mainHeader.invoke(channel, ...args);
 | 
			
		||||
        }
 | 
			
		||||
        // return Promise.resolve();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    showSettingsWindow(element) {
 | 
			
		||||
        if (this.wasJustDragged) return;
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`);
 | 
			
		||||
            
 | 
			
		||||
            ipcRenderer.send('cancel-hide-settings-window');
 | 
			
		||||
            window.api.mainHeader.cancelHideSettingsWindow();
 | 
			
		||||
 | 
			
		||||
            if (element) {
 | 
			
		||||
                const { left, top, width, height } = element.getBoundingClientRect();
 | 
			
		||||
                ipcRenderer.send('show-settings-window', {
 | 
			
		||||
                window.api.mainHeader.showSettingsWindow({
 | 
			
		||||
                    x: left,
 | 
			
		||||
                    y: top,
 | 
			
		||||
                    width,
 | 
			
		||||
@ -542,9 +544,9 @@ export class MainHeader extends LitElement {
 | 
			
		||||
 | 
			
		||||
    hideSettingsWindow() {
 | 
			
		||||
        if (this.wasJustDragged) return;
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            console.log(`[MainHeader] hideSettingsWindow called at ${Date.now()}`);
 | 
			
		||||
            window.require('electron').ipcRenderer.send('hide-settings-window');
 | 
			
		||||
            window.api.mainHeader.hideSettingsWindow();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -557,15 +559,26 @@ export class MainHeader extends LitElement {
 | 
			
		||||
        this.isTogglingSession = true;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const channel = 'toggle-feature';
 | 
			
		||||
            const args = ['listen'];
 | 
			
		||||
            await this.invoke(channel, ...args);
 | 
			
		||||
            const channel = 'listen:changeSession';
 | 
			
		||||
            const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
 | 
			
		||||
            await this.invoke(channel, listenButtonText);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('IPC invoke for session toggle failed:', error);
 | 
			
		||||
            console.error('IPC invoke for session change failed:', error);
 | 
			
		||||
            this.isTogglingSession = false;
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _handleAskClick() {
 | 
			
		||||
        if (this.wasJustDragged) return;
 | 
			
		||||
 | 
			
		||||
        try {
 | 
			
		||||
            const channel = 'ask:toggleAskButton';
 | 
			
		||||
            await this.invoke(channel);
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('IPC invoke for ask button failed:', error);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    renderShortcut(accelerator) {
 | 
			
		||||
        if (!accelerator) return html``;
 | 
			
		||||
@ -591,11 +604,13 @@ export class MainHeader extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
 | 
			
		||||
    
 | 
			
		||||
        const buttonClasses = {
 | 
			
		||||
            active: this.actionText === 'Stop',
 | 
			
		||||
            done: this.actionText === 'Done',
 | 
			
		||||
            active: listenButtonText === 'Stop',
 | 
			
		||||
            done: listenButtonText === 'Done',
 | 
			
		||||
        };
 | 
			
		||||
        const showStopIcon = this.actionText === 'Stop' || this.actionText === 'Done';
 | 
			
		||||
        const showStopIcon = listenButtonText === 'Stop' || listenButtonText === 'Done';
 | 
			
		||||
 | 
			
		||||
        return html`
 | 
			
		||||
            <div class="header" @mousedown=${this.handleMouseDown}>
 | 
			
		||||
@ -612,7 +627,7 @@ export class MainHeader extends LitElement {
 | 
			
		||||
                        `
 | 
			
		||||
                        : html`
 | 
			
		||||
                            <div class="action-text">
 | 
			
		||||
                                <div class="action-text-content">${this.actionText}</div>
 | 
			
		||||
                                <div class="action-text-content">${listenButtonText}</div>
 | 
			
		||||
                            </div>
 | 
			
		||||
                            <div class="listen-icon">
 | 
			
		||||
                                ${showStopIcon
 | 
			
		||||
@ -632,7 +647,7 @@ export class MainHeader extends LitElement {
 | 
			
		||||
                        `}
 | 
			
		||||
                </button>
 | 
			
		||||
 | 
			
		||||
                <div class="header-actions ask-action" @click=${() => this.invoke('toggle-feature', 'ask')}>
 | 
			
		||||
                <div class="header-actions ask-action" @click=${() => this._handleAskClick()}>
 | 
			
		||||
                    <div class="action-text">
 | 
			
		||||
                        <div class="action-text-content">Ask</div>
 | 
			
		||||
                    </div>
 | 
			
		||||
@ -288,13 +288,12 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async checkPermissions() {
 | 
			
		||||
        if (!window.require || this.isChecking) return;
 | 
			
		||||
        if (!window.api || this.isChecking) return;
 | 
			
		||||
        
 | 
			
		||||
        this.isChecking = true;
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const permissions = await ipcRenderer.invoke('check-system-permissions');
 | 
			
		||||
            const permissions = await window.api.permissionHeader.checkSystemPermissions();
 | 
			
		||||
            console.log('[PermissionHeader] Permission check result:', permissions);
 | 
			
		||||
            
 | 
			
		||||
            const prevMic = this.microphoneGranted;
 | 
			
		||||
@ -324,13 +323,12 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleMicrophoneClick() {
 | 
			
		||||
        if (!window.require || this.microphoneGranted === 'granted') return;
 | 
			
		||||
        if (!window.api || this.microphoneGranted === 'granted') return;
 | 
			
		||||
        
 | 
			
		||||
        console.log('[PermissionHeader] Requesting microphone permission...');
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const result = await ipcRenderer.invoke('check-system-permissions');
 | 
			
		||||
            const result = await window.api.permissionHeader.checkSystemPermissions();
 | 
			
		||||
            console.log('[PermissionHeader] Microphone permission result:', result);
 | 
			
		||||
            
 | 
			
		||||
            if (result.microphone === 'granted') {
 | 
			
		||||
@ -340,7 +338,7 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
              }
 | 
			
		||||
            
 | 
			
		||||
              if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
 | 
			
		||||
                const res = await ipcRenderer.invoke('request-microphone-permission');
 | 
			
		||||
                const res = await window.api.permissionHeader.requestMicrophonePermission();
 | 
			
		||||
                if (res.status === 'granted' || res.success === true) {
 | 
			
		||||
                    this.microphoneGranted = 'granted';
 | 
			
		||||
                    this.requestUpdate();
 | 
			
		||||
@ -357,13 +355,12 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleScreenClick() {
 | 
			
		||||
        if (!window.require || this.screenGranted === 'granted') return;
 | 
			
		||||
        if (!window.api || this.screenGranted === 'granted') return;
 | 
			
		||||
        
 | 
			
		||||
        console.log('[PermissionHeader] Checking screen recording permission...');
 | 
			
		||||
        const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        
 | 
			
		||||
        try {
 | 
			
		||||
            const permissions = await ipcRenderer.invoke('check-system-permissions');
 | 
			
		||||
            const permissions = await window.api.permissionHeader.checkSystemPermissions();
 | 
			
		||||
            console.log('[PermissionHeader] Screen permission check result:', permissions);
 | 
			
		||||
            
 | 
			
		||||
            if (permissions.screen === 'granted') {
 | 
			
		||||
@ -373,7 +370,7 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
            }
 | 
			
		||||
            if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
 | 
			
		||||
            console.log('[PermissionHeader] Opening screen recording preferences...');
 | 
			
		||||
            await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
 | 
			
		||||
            await window.api.permissionHeader.openSystemPreferences('screen-recording');
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
            // Check permissions again after a delay
 | 
			
		||||
@ -389,10 +386,9 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
            this.microphoneGranted === 'granted' && 
 | 
			
		||||
            this.screenGranted === 'granted') {
 | 
			
		||||
            // Mark permissions as completed
 | 
			
		||||
            if (window.require) {
 | 
			
		||||
                const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            if (window.api) {
 | 
			
		||||
                try {
 | 
			
		||||
                    await ipcRenderer.invoke('mark-permissions-completed');
 | 
			
		||||
                    await window.api.permissionHeader.markPermissionsCompleted();
 | 
			
		||||
                    console.log('[PermissionHeader] Marked permissions as completed');
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
                    console.error('[PermissionHeader] Error marking permissions as completed:', error);
 | 
			
		||||
@ -405,8 +401,8 @@ export class PermissionHeader extends LitElement {
 | 
			
		||||
 | 
			
		||||
    handleClose() {
 | 
			
		||||
        console.log('Close button clicked');
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            window.require('electron').ipcRenderer.invoke('quit-application');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.common.quitApplication();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1,10 +1,10 @@
 | 
			
		||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
 | 
			
		||||
import { SettingsView } from '../features/settings/SettingsView.js';
 | 
			
		||||
import { AssistantView } from '../features/listen/AssistantView.js';
 | 
			
		||||
import { AskView } from '../features/ask/AskView.js';
 | 
			
		||||
import { ShortcutSettingsView } from '../features/settings/ShortCutSettingsView.js';
 | 
			
		||||
import { SettingsView } from '../settings/SettingsView.js';
 | 
			
		||||
import { ListenView } from '../listen/ListenView.js';
 | 
			
		||||
import { AskView } from '../ask/AskView.js';
 | 
			
		||||
import { ShortcutSettingsView } from '../settings/ShortCutSettingsView.js';
 | 
			
		||||
 | 
			
		||||
import '../features/listen/renderer/renderer.js';
 | 
			
		||||
import '../listen/audioCore/renderer.js';
 | 
			
		||||
 | 
			
		||||
export class PickleGlassApp extends LitElement {
 | 
			
		||||
    static styles = css`
 | 
			
		||||
@ -17,7 +17,7 @@ export class PickleGlassApp extends LitElement {
 | 
			
		||||
            border-radius: 7px;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        assistant-view {
 | 
			
		||||
        listen-view {
 | 
			
		||||
            display: block;
 | 
			
		||||
            width: 100%;
 | 
			
		||||
            height: 100%;
 | 
			
		||||
@ -74,33 +74,21 @@ export class PickleGlassApp extends LitElement {
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
        
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            
 | 
			
		||||
            ipcRenderer.on('click-through-toggled', (_, isEnabled) => {
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.pickleGlassApp.onClickThroughToggled((_, isEnabled) => {
 | 
			
		||||
                this._isClickThrough = isEnabled;
 | 
			
		||||
            });
 | 
			
		||||
            // ipcRenderer.on('start-listening-session', () => {
 | 
			
		||||
            //     console.log('Received start-listening-session command, calling handleListenClick.');
 | 
			
		||||
            //     this.handleListenClick();
 | 
			
		||||
            // });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    disconnectedCallback() {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.removeAllListeners('click-through-toggled');
 | 
			
		||||
            // ipcRenderer.removeAllListeners('start-listening-session');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.pickleGlassApp.removeAllClickThroughListeners();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    updated(changedProperties) {
 | 
			
		||||
        // if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) {
 | 
			
		||||
        //     this.requestWindowResize();
 | 
			
		||||
        // }
 | 
			
		||||
 | 
			
		||||
        if (changedProperties.has('currentView')) {
 | 
			
		||||
            const viewContainer = this.shadowRoot?.querySelector('.view-container');
 | 
			
		||||
            if (viewContainer) {
 | 
			
		||||
@ -129,40 +117,9 @@ export class PickleGlassApp extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    // async handleListenClick() {
 | 
			
		||||
    //     if (window.require) {
 | 
			
		||||
    //         const { ipcRenderer } = window.require('electron');
 | 
			
		||||
    //         const isActive = await ipcRenderer.invoke('is-session-active');
 | 
			
		||||
    //         // if (isActive) {
 | 
			
		||||
    //         //     console.log('Session is already active. No action needed.');
 | 
			
		||||
    //         //     return;
 | 
			
		||||
    //         // }
 | 
			
		||||
    //     }
 | 
			
		||||
 | 
			
		||||
    //     if (window.pickleGlass) {
 | 
			
		||||
    //         // await window.pickleGlass.initializeopenai(this.selectedProfile, this.selectedLanguage);
 | 
			
		||||
    //         window.pickleGlass.startCapture(this.selectedScreenshotInterval, this.selectedImageQuality);
 | 
			
		||||
    //     }
 | 
			
		||||
 | 
			
		||||
    //     // 🔄 Clear previous summary/analysis when a new listening session begins
 | 
			
		||||
    //     this.structuredData = {
 | 
			
		||||
    //         summary: [],
 | 
			
		||||
    //         topic: { header: '', bullets: [] },
 | 
			
		||||
    //         actions: [],
 | 
			
		||||
    //         followUps: [],
 | 
			
		||||
    //     };
 | 
			
		||||
 | 
			
		||||
    //     this.currentResponseIndex = -1;
 | 
			
		||||
    //     this.startTime = Date.now();
 | 
			
		||||
    //     this.currentView = 'listen';
 | 
			
		||||
    //     this.isMainViewVisible = true;
 | 
			
		||||
    // }
 | 
			
		||||
 | 
			
		||||
    async handleClose() {
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            await ipcRenderer.invoke('quit-application');
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            await window.api.common.quitApplication();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -172,12 +129,12 @@ export class PickleGlassApp extends LitElement {
 | 
			
		||||
    render() {
 | 
			
		||||
        switch (this.currentView) {
 | 
			
		||||
            case 'listen':
 | 
			
		||||
                return html`<assistant-view
 | 
			
		||||
                return html`<listen-view
 | 
			
		||||
                    .currentResponseIndex=${this.currentResponseIndex}
 | 
			
		||||
                    .selectedProfile=${this.selectedProfile}
 | 
			
		||||
                    .structuredData=${this.structuredData}
 | 
			
		||||
                    @response-index-changed=${e => (this.currentResponseIndex = e.detail.index)}
 | 
			
		||||
                ></assistant-view>`;
 | 
			
		||||
                ></listen-view>`;
 | 
			
		||||
            case 'ask':
 | 
			
		||||
                return html`<ask-view></ask-view>`;
 | 
			
		||||
            case 'settings':
 | 
			
		||||
							
								
								
									
										234
									
								
								src/ui/app/WelcomeHeader.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,234 @@
 | 
			
		||||
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;
 | 
			
		||||
        }
 | 
			
		||||
    `;
 | 
			
		||||
 | 
			
		||||
    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.require) {
 | 
			
		||||
            window.require('electron').ipcRenderer.invoke('quit-application');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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() {
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            window.require('electron').shell.openExternal('https://pickleglass.com/privacy');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('welcome-header', WelcomeHeader);
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" />
 | 
			
		||||
        <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
 | 
			
		||||
        <title>Pickle Glass Content</title>
 | 
			
		||||
        <style>
 | 
			
		||||
            :root {
 | 
			
		||||
@ -230,7 +230,7 @@
 | 
			
		||||
    <body>
 | 
			
		||||
        <script src="../assets/marked-4.3.0.min.js"></script>
 | 
			
		||||
        
 | 
			
		||||
        <script type="module" src="../../public/build/content.js"></script>
 | 
			
		||||
        <script type="module" src="../../../public/build/content.js"></script>
 | 
			
		||||
 | 
			
		||||
        <pickle-glass-app id="pickle-glass"></pickle-glass-app>
 | 
			
		||||
        
 | 
			
		||||
@ -238,15 +238,13 @@
 | 
			
		||||
            window.addEventListener('DOMContentLoaded', () => {
 | 
			
		||||
                const app = document.getElementById('pickle-glass');
 | 
			
		||||
        
 | 
			
		||||
                if (window.require) {
 | 
			
		||||
                    const { ipcRenderer } = window.require('electron');
 | 
			
		||||
        
 | 
			
		||||
                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.`);
 | 
			
		||||
                            ipcRenderer.send('animation-finished');
 | 
			
		||||
                            window.api.content.sendAnimationFinished();
 | 
			
		||||
        
 | 
			
		||||
                            // 완료 후 애니메이션 클래스 정리
 | 
			
		||||
                            app.classList.remove('window-sliding-up', 'settings-window-hide');
 | 
			
		||||
@ -257,26 +255,26 @@
 | 
			
		||||
                        }
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    ipcRenderer.on('window-show-animation', () => {
 | 
			
		||||
                    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');
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    ipcRenderer.on('window-hide-animation', () => {
 | 
			
		||||
                    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');
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    ipcRenderer.on('settings-window-hide-animation', () => {
 | 
			
		||||
                    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 ---
 | 
			
		||||
                    ipcRenderer.on('listen-window-move-to-center', () => {
 | 
			
		||||
                    window.api.content.onListenWindowMoveToCenter(() => {
 | 
			
		||||
                        console.log('Moving listen window to center');
 | 
			
		||||
                        app.classList.add('listen-window-moving');
 | 
			
		||||
                        app.classList.remove('listen-window-left');
 | 
			
		||||
@ -287,7 +285,7 @@
 | 
			
		||||
                        }, 350);
 | 
			
		||||
                    });
 | 
			
		||||
        
 | 
			
		||||
                    ipcRenderer.on('listen-window-move-to-left', () => {
 | 
			
		||||
                    window.api.content.onListenWindowMoveToLeft(() => {
 | 
			
		||||
                        console.log('Moving listen window to left');
 | 
			
		||||
                        app.classList.add('listen-window-moving');
 | 
			
		||||
                        app.classList.remove('listen-window-center');
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<html>
 | 
			
		||||
    <head>
 | 
			
		||||
        <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" />
 | 
			
		||||
        <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
 | 
			
		||||
        <title>Pickle Glass Header</title>
 | 
			
		||||
        <style>
 | 
			
		||||
            html,
 | 
			
		||||
@ -17,7 +17,7 @@
 | 
			
		||||
        <div id="header-container" tabindex="0" style="outline: none;">
 | 
			
		||||
        </div>
 | 
			
		||||
 | 
			
		||||
        <script type="module" src="../../public/build/header.js"></script>
 | 
			
		||||
        <script type="module" src="../../../public/build/header.js"></script>
 | 
			
		||||
        <script>
 | 
			
		||||
            const params = new URLSearchParams(window.location.search);
 | 
			
		||||
            if (params.get('glass') === 'true') {
 | 
			
		||||
@ -1,4 +1,4 @@
 | 
			
		||||
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
 | 
			
		||||
import { html, css, LitElement } from '../../ui/assets/lit-core-2.7.4.min.js';
 | 
			
		||||
 | 
			
		||||
export class AskView extends LitElement {
 | 
			
		||||
    static properties = {
 | 
			
		||||
@ -719,28 +719,21 @@ export class AskView extends LitElement {
 | 
			
		||||
        this.headerText = 'AI Response';
 | 
			
		||||
        this.headerAnimating = false;
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.accumulatedResponse = '';
 | 
			
		||||
 | 
			
		||||
        this.marked = null;
 | 
			
		||||
        this.hljs = null;
 | 
			
		||||
        this.DOMPurify = null;
 | 
			
		||||
        this.isLibrariesLoaded = false;
 | 
			
		||||
 | 
			
		||||
        this.handleStreamChunk = this.handleStreamChunk.bind(this);
 | 
			
		||||
        this.handleStreamEnd = this.handleStreamEnd.bind(this);
 | 
			
		||||
 | 
			
		||||
        this.handleSendText = this.handleSendText.bind(this);
 | 
			
		||||
        this.handleGlobalSendRequest = this.handleGlobalSendRequest.bind(this);
 | 
			
		||||
        this.handleTextKeydown = this.handleTextKeydown.bind(this);
 | 
			
		||||
        this.closeResponsePanel = this.closeResponsePanel.bind(this);
 | 
			
		||||
        this.handleCopy = this.handleCopy.bind(this);
 | 
			
		||||
        this.clearResponseContent = this.clearResponseContent.bind(this);
 | 
			
		||||
        this.processAssistantQuestion = this.processAssistantQuestion.bind(this);
 | 
			
		||||
        this.handleToggleTextInput = this.handleToggleTextInput.bind(this);
 | 
			
		||||
        this.handleEscKey = this.handleEscKey.bind(this);
 | 
			
		||||
        this.handleDocumentClick = this.handleDocumentClick.bind(this);
 | 
			
		||||
        this.handleWindowBlur = this.handleWindowBlur.bind(this);
 | 
			
		||||
 | 
			
		||||
        this.handleScroll = this.handleScroll.bind(this);
 | 
			
		||||
        this.handleCloseAskWindow = this.handleCloseAskWindow.bind(this);
 | 
			
		||||
        this.handleCloseIfNoContent = this.handleCloseIfNoContent.bind(this);
 | 
			
		||||
 | 
			
		||||
        this.loadLibraries();
 | 
			
		||||
 | 
			
		||||
@ -748,6 +741,86 @@ export class AskView extends LitElement {
 | 
			
		||||
        this.isThrottled = false;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
 | 
			
		||||
        console.log('📱 AskView connectedCallback - IPC 이벤트 리스너 설정');
 | 
			
		||||
 | 
			
		||||
        document.addEventListener('keydown', this.handleEscKey);
 | 
			
		||||
 | 
			
		||||
        this.resizeObserver = new ResizeObserver(entries => {
 | 
			
		||||
            for (const entry of entries) {
 | 
			
		||||
                const needed = entry.contentRect.height;
 | 
			
		||||
                const current = window.innerHeight;
 | 
			
		||||
 | 
			
		||||
                if (needed > current - 4) {
 | 
			
		||||
                    this.requestWindowResize(Math.ceil(needed));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const container = this.shadowRoot?.querySelector('.ask-container');
 | 
			
		||||
        if (container) this.resizeObserver.observe(container);
 | 
			
		||||
 | 
			
		||||
        this.handleQuestionFromAssistant = (event, 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');
 | 
			
		||||
                if (!this.showTextInput) {
 | 
			
		||||
                    this.showTextInput = true;
 | 
			
		||||
                    this.requestUpdate();
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            window.api.askView.onScrollResponseUp(() => this.handleScroll('up'));
 | 
			
		||||
            window.api.askView.onScrollResponseDown(() => this.handleScroll('down'));
 | 
			
		||||
            window.api.askView.onAskStateUpdate((event, newState) => {
 | 
			
		||||
                this.currentResponse = newState.currentResponse;
 | 
			
		||||
                this.currentQuestion = newState.currentQuestion;
 | 
			
		||||
                this.isLoading = newState.isLoading;
 | 
			
		||||
                this.isStreaming = newState.isStreaming;
 | 
			
		||||
                this.showTextInput = newState.showTextInput;
 | 
			
		||||
            });
 | 
			
		||||
            console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    disconnectedCallback() {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        this.resizeObserver?.disconnect();
 | 
			
		||||
 | 
			
		||||
        console.log('📱 AskView disconnectedCallback - IPC 이벤트 리스너 제거');
 | 
			
		||||
 | 
			
		||||
        document.removeEventListener('keydown', this.handleEscKey);
 | 
			
		||||
 | 
			
		||||
        if (this.copyTimeout) {
 | 
			
		||||
            clearTimeout(this.copyTimeout);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.headerAnimationTimeout) {
 | 
			
		||||
            clearTimeout(this.headerAnimationTimeout);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.streamingTimeout) {
 | 
			
		||||
            clearTimeout(this.streamingTimeout);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Object.values(this.lineCopyTimeouts).forEach(timeout => clearTimeout(timeout));
 | 
			
		||||
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.askView.removeOnAskStateUpdate(this.handleAskStateUpdate);
 | 
			
		||||
            window.api.askView.removeOnShowTextInput(this.handleShowTextInput);
 | 
			
		||||
            window.api.askView.removeOnScrollResponseUp(this.handleScroll);
 | 
			
		||||
            window.api.askView.removeOnScrollResponseDown(this.handleScroll);
 | 
			
		||||
            console.log('✅ AskView: IPC 이벤트 리스너 제거 필요');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    async loadLibraries() {
 | 
			
		||||
        try {
 | 
			
		||||
            if (!window.marked) {
 | 
			
		||||
@ -804,38 +877,46 @@ export class AskView extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleDocumentClick(e) {
 | 
			
		||||
    handleCloseAskWindow() {
 | 
			
		||||
        this.clearResponseContent();
 | 
			
		||||
        window.api.askView.closeAskWindow();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleCloseIfNoContent() {
 | 
			
		||||
        if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
 | 
			
		||||
            const askContainer = this.shadowRoot?.querySelector('.ask-container');
 | 
			
		||||
            if (askContainer && !e.composedPath().includes(askContainer)) {
 | 
			
		||||
                this.closeIfNoContent();
 | 
			
		||||
            }
 | 
			
		||||
            this.handleCloseAskWindow();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleEscKey(e) {
 | 
			
		||||
        if (e.key === 'Escape') {
 | 
			
		||||
            e.preventDefault();
 | 
			
		||||
            this.closeResponsePanel();
 | 
			
		||||
            this.handleCloseIfNoContent();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleWindowBlur() {
 | 
			
		||||
        if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
 | 
			
		||||
            // If there's no active content, ask the main process to close this window.
 | 
			
		||||
            if (window.require) {
 | 
			
		||||
                const { ipcRenderer } = window.require('electron');
 | 
			
		||||
                ipcRenderer.invoke('close-ask-window-if-empty');
 | 
			
		||||
    clearResponseContent() {
 | 
			
		||||
        this.currentResponse = '';
 | 
			
		||||
        this.currentQuestion = '';
 | 
			
		||||
        this.isLoading = false;
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.headerText = 'AI Response';
 | 
			
		||||
        this.showTextInput = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleInputFocus() {
 | 
			
		||||
        this.isInputFocused = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    focusTextInput() {
 | 
			
		||||
        requestAnimationFrame(() => {
 | 
			
		||||
            const textInput = this.shadowRoot?.getElementById('textInput');
 | 
			
		||||
            if (textInput) {
 | 
			
		||||
                textInput.focus();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    closeIfNoContent() {
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.invoke('force-close-window', 'ask');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    loadScript(src) {
 | 
			
		||||
        return new Promise((resolve, reject) => {
 | 
			
		||||
@ -875,125 +956,6 @@ export class AskView extends LitElement {
 | 
			
		||||
        return text;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    connectedCallback() {
 | 
			
		||||
        super.connectedCallback();
 | 
			
		||||
 | 
			
		||||
        console.log('📱 AskView connectedCallback - IPC 이벤트 리스너 설정');
 | 
			
		||||
 | 
			
		||||
        document.addEventListener('click', this.handleDocumentClick, true);
 | 
			
		||||
        document.addEventListener('keydown', this.handleEscKey);
 | 
			
		||||
 | 
			
		||||
        this.resizeObserver = new ResizeObserver(entries => {
 | 
			
		||||
            for (const entry of entries) {
 | 
			
		||||
                const needed = entry.contentRect.height;
 | 
			
		||||
                const current = window.innerHeight;
 | 
			
		||||
 | 
			
		||||
                if (needed > current - 4) {
 | 
			
		||||
                    this.requestWindowResize(Math.ceil(needed));
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
 | 
			
		||||
        const container = this.shadowRoot?.querySelector('.ask-container');
 | 
			
		||||
        if (container) this.resizeObserver.observe(container);
 | 
			
		||||
 | 
			
		||||
        this.handleQuestionFromAssistant = (event, question) => {
 | 
			
		||||
            console.log('📨 AskView: Received question from AssistantView:', question);
 | 
			
		||||
            this.currentResponse = '';
 | 
			
		||||
            this.isStreaming = false;
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
 | 
			
		||||
            this.currentQuestion = question;
 | 
			
		||||
            this.isLoading = true;
 | 
			
		||||
            this.showTextInput = false;
 | 
			
		||||
            this.headerText = 'analyzing screen...';
 | 
			
		||||
            this.startHeaderAnimation();
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
 | 
			
		||||
            this.processAssistantQuestion(question);
 | 
			
		||||
        };
 | 
			
		||||
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.on('ask-global-send', this.handleGlobalSendRequest);
 | 
			
		||||
            ipcRenderer.on('toggle-text-input', this.handleToggleTextInput);
 | 
			
		||||
            ipcRenderer.on('receive-question-from-assistant', this.handleQuestionFromAssistant);
 | 
			
		||||
            ipcRenderer.on('hide-text-input', () => {
 | 
			
		||||
                console.log('📤 Hide text input signal received');
 | 
			
		||||
                this.showTextInput = false;
 | 
			
		||||
                this.requestUpdate();
 | 
			
		||||
            });
 | 
			
		||||
            ipcRenderer.on('clear-ask-response', () => {
 | 
			
		||||
                console.log('📤 Clear response signal received');
 | 
			
		||||
                this.currentResponse = '';
 | 
			
		||||
                this.isStreaming = false;
 | 
			
		||||
                this.isLoading = false;
 | 
			
		||||
                this.headerText = 'AI Response';
 | 
			
		||||
                this.requestUpdate();
 | 
			
		||||
            });
 | 
			
		||||
            ipcRenderer.on('window-hide-animation', () => {
 | 
			
		||||
                console.log('📤 Ask window hiding - clearing response content');
 | 
			
		||||
                setTimeout(() => {
 | 
			
		||||
                    this.clearResponseContent();
 | 
			
		||||
                }, 250);
 | 
			
		||||
            });
 | 
			
		||||
            ipcRenderer.on('window-blur', this.handleWindowBlur);
 | 
			
		||||
            ipcRenderer.on('window-did-show', () => {
 | 
			
		||||
                if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
 | 
			
		||||
                    this.focusTextInput();
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
 | 
			
		||||
            ipcRenderer.on('ask-response-chunk', this.handleStreamChunk);
 | 
			
		||||
            ipcRenderer.on('ask-response-stream-end', this.handleStreamEnd);
 | 
			
		||||
 | 
			
		||||
            ipcRenderer.on('scroll-response-up', () => this.handleScroll('up'));
 | 
			
		||||
            ipcRenderer.on('scroll-response-down', () => this.handleScroll('down'));
 | 
			
		||||
            console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    disconnectedCallback() {
 | 
			
		||||
        super.disconnectedCallback();
 | 
			
		||||
        this.resizeObserver?.disconnect();
 | 
			
		||||
 | 
			
		||||
        console.log('📱 AskView disconnectedCallback - IPC 이벤트 리스너 제거');
 | 
			
		||||
 | 
			
		||||
        document.removeEventListener('click', this.handleDocumentClick, true);
 | 
			
		||||
        document.removeEventListener('keydown', this.handleEscKey);
 | 
			
		||||
 | 
			
		||||
        if (this.copyTimeout) {
 | 
			
		||||
            clearTimeout(this.copyTimeout);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.headerAnimationTimeout) {
 | 
			
		||||
            clearTimeout(this.headerAnimationTimeout);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (this.streamingTimeout) {
 | 
			
		||||
            clearTimeout(this.streamingTimeout);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        Object.values(this.lineCopyTimeouts).forEach(timeout => clearTimeout(timeout));
 | 
			
		||||
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.removeListener('ask-global-send', this.handleGlobalSendRequest);
 | 
			
		||||
            ipcRenderer.removeListener('toggle-text-input', this.handleToggleTextInput);
 | 
			
		||||
            ipcRenderer.removeListener('clear-ask-response', () => { });
 | 
			
		||||
            ipcRenderer.removeListener('hide-text-input', () => { });
 | 
			
		||||
            ipcRenderer.removeListener('window-hide-animation', () => { });
 | 
			
		||||
            ipcRenderer.removeListener('window-blur', this.handleWindowBlur);
 | 
			
		||||
 | 
			
		||||
            ipcRenderer.removeListener('ask-response-chunk', this.handleStreamChunk);
 | 
			
		||||
            ipcRenderer.removeListener('ask-response-stream-end', this.handleStreamEnd);
 | 
			
		||||
 | 
			
		||||
            ipcRenderer.removeListener('scroll-response-up', () => this.handleScroll('up'));
 | 
			
		||||
            ipcRenderer.removeListener('scroll-response-down', () => this.handleScroll('down'));
 | 
			
		||||
            console.log('✅ AskView: IPC 이벤트 리스너 제거 완료');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleScroll(direction) {
 | 
			
		||||
        const scrollableElement = this.shadowRoot.querySelector('#responseContainer');
 | 
			
		||||
        if (scrollableElement) {
 | 
			
		||||
@ -1006,56 +968,26 @@ export class AskView extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // --- 스트리밍 처리 핸들러 ---
 | 
			
		||||
    handleStreamChunk(event, { token }) {
 | 
			
		||||
        if (!this.isStreaming) {
 | 
			
		||||
            this.isStreaming = true;
 | 
			
		||||
            this.isLoading = false;
 | 
			
		||||
            this.accumulatedResponse = '';
 | 
			
		||||
            const container = this.shadowRoot.getElementById('responseContainer');
 | 
			
		||||
            if (container) container.innerHTML = '';
 | 
			
		||||
            this.headerText = 'AI Response';
 | 
			
		||||
            this.headerAnimating = false;
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
        }
 | 
			
		||||
        this.accumulatedResponse += token;
 | 
			
		||||
        this.renderContent();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleStreamEnd() {
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.currentResponse = this.accumulatedResponse;
 | 
			
		||||
        if (this.headerText !== 'AI Response') {
 | 
			
		||||
            this.headerText = 'AI Response';
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
        }
 | 
			
		||||
        this.renderContent();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // ✨ 렌더링 로직 통합
 | 
			
		||||
    renderContent() {
 | 
			
		||||
        if (!this.isLoading && !this.isStreaming && !this.currentResponse) {
 | 
			
		||||
            const responseContainer = this.shadowRoot.getElementById('responseContainer');
 | 
			
		||||
            if (responseContainer) responseContainer.innerHTML = '<div class="empty-state">Ask a question to see the response here</div>';
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        const responseContainer = this.shadowRoot.getElementById('responseContainer');
 | 
			
		||||
        if (!responseContainer) return;
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
        // ✨ 로딩 상태를 먼저 확인
 | 
			
		||||
        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>`;
 | 
			
		||||
            responseContainer.innerHTML = `<div class="loading-dots">...</div>`;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        let textToRender = this.isStreaming ? this.accumulatedResponse : this.currentResponse;
 | 
			
		||||
 | 
			
		||||
        // 불완전한 마크다운 수정
 | 
			
		||||
        textToRender = this.fixIncompleteMarkdown(textToRender);
 | 
			
		||||
        
 | 
			
		||||
        // ✨ 응답이 없을 때의 처리
 | 
			
		||||
        if (!this.currentResponse) {
 | 
			
		||||
            responseContainer.innerHTML = `<div class="empty-state">...</div>`;
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
        
 | 
			
		||||
        let textToRender = this.fixIncompleteMarkdown(this.currentResponse);
 | 
			
		||||
        textToRender = this.fixIncompleteCodeBlocks(textToRender);
 | 
			
		||||
    
 | 
			
		||||
 | 
			
		||||
        if (this.isLibrariesLoaded && this.marked && this.DOMPurify) {
 | 
			
		||||
            try {
 | 
			
		||||
@ -1136,27 +1068,10 @@ export class AskView extends LitElement {
 | 
			
		||||
        this.adjustWindowHeightThrottled();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    clearResponseContent() {
 | 
			
		||||
        this.currentResponse = '';
 | 
			
		||||
        this.currentQuestion = '';
 | 
			
		||||
        this.isLoading = false;
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.headerText = 'AI Response';
 | 
			
		||||
        this.showTextInput = true;
 | 
			
		||||
        this.accumulatedResponse = '';
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
        this.renderContent(); // 👈 updateResponseContent() 대신 renderContent() 호출
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleToggleTextInput() {
 | 
			
		||||
        this.showTextInput = !this.showTextInput;
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    requestWindowResize(targetHeight) {
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.invoke('adjust-window-height', targetHeight);
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.askView.adjustWindowHeight(targetHeight);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -1196,13 +1111,6 @@ export class AskView extends LitElement {
 | 
			
		||||
            .replace(/`(.*?)`/g, '<code>$1</code>');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    closeResponsePanel() {
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.invoke('force-close-window', 'ask');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    fixIncompleteMarkdown(text) {
 | 
			
		||||
        if (!text) return text;
 | 
			
		||||
 | 
			
		||||
@ -1240,29 +1148,6 @@ export class AskView extends LitElement {
 | 
			
		||||
        return text;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // ✨ processAssistantQuestion 수정
 | 
			
		||||
    async processAssistantQuestion(question) {
 | 
			
		||||
        this.currentQuestion = question;
 | 
			
		||||
        this.showTextInput = false;
 | 
			
		||||
        this.isLoading = true;
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.currentResponse = '';
 | 
			
		||||
        this.accumulatedResponse = '';
 | 
			
		||||
        this.startHeaderAnimation();
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
        this.renderContent();
 | 
			
		||||
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.invoke('ask:sendMessage', question).catch(error => {
 | 
			
		||||
                console.error('Error processing assistant question:', error);
 | 
			
		||||
                this.isLoading = false;
 | 
			
		||||
                this.isStreaming = false;
 | 
			
		||||
                this.currentResponse = `Error: ${error.message}`;
 | 
			
		||||
                this.renderContent();
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleCopy() {
 | 
			
		||||
        if (this.copyState === 'copied') return;
 | 
			
		||||
@ -1332,33 +1217,16 @@ export class AskView extends LitElement {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleSendText() {
 | 
			
		||||
    async handleSendText(e, overridingText = '') {
 | 
			
		||||
        const textInput = this.shadowRoot?.getElementById('textInput');
 | 
			
		||||
        if (!textInput) return;
 | 
			
		||||
        const text = textInput.value.trim();
 | 
			
		||||
        const text = (overridingText || textInput?.value || '').trim();
 | 
			
		||||
        if (!text) return;
 | 
			
		||||
 | 
			
		||||
        textInput.value = '';
 | 
			
		||||
 | 
			
		||||
        this.currentQuestion = text;
 | 
			
		||||
        this.lineCopyState = {};
 | 
			
		||||
        this.showTextInput = false;
 | 
			
		||||
        this.isLoading = true;
 | 
			
		||||
        this.isStreaming = false;
 | 
			
		||||
        this.currentResponse = '';
 | 
			
		||||
        this.accumulatedResponse = '';
 | 
			
		||||
        this.startHeaderAnimation();
 | 
			
		||||
        this.requestUpdate();
 | 
			
		||||
        this.renderContent();
 | 
			
		||||
 | 
			
		||||
        if (window.require) {
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.invoke('ask:sendMessage', text).catch(error => {
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
            window.api.askView.sendMessage(text).catch(error => {
 | 
			
		||||
                console.error('Error sending text:', error);
 | 
			
		||||
                this.isLoading = false;
 | 
			
		||||
                this.isStreaming = false;
 | 
			
		||||
                this.currentResponse = `Error: ${error.message}`;
 | 
			
		||||
                this.renderContent();
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -1380,50 +1248,25 @@ export class AskView extends LitElement {
 | 
			
		||||
 | 
			
		||||
    updated(changedProperties) {
 | 
			
		||||
        super.updated(changedProperties);
 | 
			
		||||
        if (changedProperties.has('isLoading')) {
 | 
			
		||||
    
 | 
			
		||||
        // ✨ isLoading 또는 currentResponse가 변경될 때마다 뷰를 다시 그립니다.
 | 
			
		||||
        if (changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
 | 
			
		||||
            this.renderContent();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (changedProperties.has('showTextInput') || changedProperties.has('isLoading')) {
 | 
			
		||||
    
 | 
			
		||||
        if (changedProperties.has('showTextInput') || changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
 | 
			
		||||
            this.adjustWindowHeightThrottled();
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
    
 | 
			
		||||
        if (changedProperties.has('showTextInput') && this.showTextInput) {
 | 
			
		||||
            this.focusTextInput();
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    focusTextInput() {
 | 
			
		||||
        requestAnimationFrame(() => {
 | 
			
		||||
            const textInput = this.shadowRoot?.getElementById('textInput');
 | 
			
		||||
            if (textInput) {
 | 
			
		||||
                textInput.focus();
 | 
			
		||||
            }
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    firstUpdated() {
 | 
			
		||||
        setTimeout(() => this.adjustWindowHeight(), 200);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleGlobalSendRequest() {
 | 
			
		||||
        const textInput = this.shadowRoot?.getElementById('textInput');
 | 
			
		||||
 | 
			
		||||
        if (!this.showTextInput) {
 | 
			
		||||
            this.showTextInput = true;
 | 
			
		||||
            this.requestUpdate();
 | 
			
		||||
            this.focusTextInput();
 | 
			
		||||
            return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        if (!textInput) return;
 | 
			
		||||
 | 
			
		||||
        textInput.focus();
 | 
			
		||||
 | 
			
		||||
        if (!textInput.value.trim()) return;
 | 
			
		||||
 | 
			
		||||
        this.handleSendText();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getTruncatedQuestion(question, maxLength = 30) {
 | 
			
		||||
        if (!question) return '';
 | 
			
		||||
@ -1431,27 +1274,11 @@ export class AskView extends LitElement {
 | 
			
		||||
        return question.substring(0, maxLength) + '...';
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleInputFocus() {
 | 
			
		||||
        this.isInputFocused = true;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    handleInputBlur(e) {
 | 
			
		||||
        this.isInputFocused = false;
 | 
			
		||||
 | 
			
		||||
        // 잠시 후 포커스가 다른 곳으로 갔는지 확인
 | 
			
		||||
        setTimeout(() => {
 | 
			
		||||
            const activeElement = this.shadowRoot?.activeElement || document.activeElement;
 | 
			
		||||
            const textInput = this.shadowRoot?.getElementById('textInput');
 | 
			
		||||
 | 
			
		||||
            // 포커스가 AskView 내부가 아니고, 응답이 없는 경우
 | 
			
		||||
            if (!this.currentResponse && !this.isLoading && !this.isStreaming && activeElement !== textInput && !this.isInputFocused) {
 | 
			
		||||
                this.closeIfNoContent();
 | 
			
		||||
            }
 | 
			
		||||
        }, 200);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    render() {
 | 
			
		||||
        const hasResponse = this.isLoading || this.currentResponse || this.isStreaming;
 | 
			
		||||
        const headerText = this.isLoading ? 'Thinking...' : 'AI Response';
 | 
			
		||||
 | 
			
		||||
        return html`
 | 
			
		||||
            <div class="ask-container">
 | 
			
		||||
@ -1464,7 +1291,7 @@ export class AskView extends LitElement {
 | 
			
		||||
                                <path d="M8 12l2 2 4-4" />
 | 
			
		||||
                            </svg>
 | 
			
		||||
                        </div>
 | 
			
		||||
                        <span class="response-label ${this.headerAnimating ? 'animating' : ''}">${this.headerText}</span>
 | 
			
		||||
                        <span class="response-label">${headerText}</span>
 | 
			
		||||
                    </div>
 | 
			
		||||
                    <div class="header-right">
 | 
			
		||||
                        <span class="question-text">${this.getTruncatedQuestion(this.currentQuestion)}</span>
 | 
			
		||||
@ -1486,7 +1313,7 @@ export class AskView extends LitElement {
 | 
			
		||||
                                    <path d="M20 6L9 17l-5-5" />
 | 
			
		||||
                                </svg>
 | 
			
		||||
                            </button>
 | 
			
		||||
                            <button class="close-button" @click=${this.closeResponsePanel}>
 | 
			
		||||
                            <button class="close-button" @click=${this.handleCloseAskWindow}>
 | 
			
		||||
                                <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 | 
			
		||||
                                    <line x1="18" y1="6" x2="6" y2="18" />
 | 
			
		||||
                                    <line x1="6" y1="6" x2="18" y2="18" />
 | 
			
		||||
@ -1509,7 +1336,6 @@ export class AskView extends LitElement {
 | 
			
		||||
                        placeholder="Ask about your screen or audio"
 | 
			
		||||
                        @keydown=${this.handleTextKeydown}
 | 
			
		||||
                        @focus=${this.handleInputFocus}
 | 
			
		||||
                        @blur=${this.handleInputBlur}
 | 
			
		||||
                    />
 | 
			
		||||
                    <button
 | 
			
		||||
                        class="submit-btn"
 | 
			
		||||
@ -1527,7 +1353,7 @@ export class AskView extends LitElement {
 | 
			
		||||
 | 
			
		||||
    // Dynamically resize the BrowserWindow to fit current content
 | 
			
		||||
    adjustWindowHeight() {
 | 
			
		||||
        if (!window.require) return;
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
 | 
			
		||||
        this.updateComplete.then(() => {
 | 
			
		||||
            const headerEl = this.shadowRoot.querySelector('.response-header');
 | 
			
		||||
@ -1544,8 +1370,7 @@ export class AskView extends LitElement {
 | 
			
		||||
 | 
			
		||||
            const targetHeight = Math.min(700, idealHeight);
 | 
			
		||||
 | 
			
		||||
            const { ipcRenderer } = window.require('electron');
 | 
			
		||||
            ipcRenderer.invoke('adjust-window-height', targetHeight);
 | 
			
		||||
            window.api.askView.adjustWindowHeight(targetHeight);
 | 
			
		||||
 | 
			
		||||
        }).catch(err => console.error('AskView adjustWindowHeight error:', err));
 | 
			
		||||
    }
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 877 B After Width: | Height: | Size: 877 B  | 
| 
		 Before Width: | Height: | Size: 226 B After Width: | Height: | Size: 226 B  | 
| 
		 Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB  | 
| 
		 Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB  | 
| 
		 Before Width: | Height: | Size: 125 KiB After Width: | Height: | Size: 125 KiB  | 
| 
		 Before Width: | Height: | Size: 68 KiB After Width: | Height: | Size: 68 KiB  | 
| 
		 Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.3 KiB  |