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