minor update + merge
This commit is contained in:
		
							parent
							
								
									8da13dcb27
								
							
						
					
					
						commit
						290ee0ed29
					
				@ -6,15 +6,15 @@ 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로부터의 요청을 수신
 | 
			
		||||
  // Renderer로부터의 요청을 수신하고 서비스로 전달
 | 
			
		||||
  initialize() {
 | 
			
		||||
    
 | 
			
		||||
    // Settings Service
 | 
			
		||||
    ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
 | 
			
		||||
    ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
 | 
			
		||||
@ -33,14 +33,12 @@ module.exports = {
 | 
			
		||||
    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());
 | 
			
		||||
@ -51,7 +49,7 @@ 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) => await whisperService.handleDownloadModel(modelId));
 | 
			
		||||
    ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
 | 
			
		||||
       
 | 
			
		||||
    // General
 | 
			
		||||
@ -60,17 +58,17 @@ 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 () => 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(event, modelName));
 | 
			
		||||
    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(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));
 | 
			
		||||
@ -101,9 +99,7 @@ module.exports = {
 | 
			
		||||
      }
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
     // ModelStateService
 | 
			
		||||
    // 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));
 | 
			
		||||
@ -114,8 +110,6 @@ module.exports = {
 | 
			
		||||
    ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
 | 
			
		||||
    ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
    console.log('[FeatureBridge] Initialized with all feature handlers.');
 | 
			
		||||
  },
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ module.exports = {
 | 
			
		||||
    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'));
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -281,35 +281,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 };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -381,6 +424,24 @@ 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 {
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,79 @@
 | 
			
		||||
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 {
 | 
			
		||||
@ -79,71 +152,77 @@ function createLLM({
 | 
			
		||||
            }
 | 
			
		||||
            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,
 | 
			
		||||
                        }
 | 
			
		||||
                    })
 | 
			
		||||
                });
 | 
			
		||||
            // 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}`);
 | 
			
		||||
                    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;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                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,
 | 
			
		||||
                        }
 | 
			
		||||
                    })
 | 
			
		||||
                });
 | 
			
		||||
            // 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}`);
 | 
			
		||||
                    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;
 | 
			
		||||
                }
 | 
			
		||||
 | 
			
		||||
                const result = await response.json();
 | 
			
		||||
                
 | 
			
		||||
                return {
 | 
			
		||||
                    content: result.message.content,
 | 
			
		||||
                    raw: result
 | 
			
		||||
                };
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('Ollama chat error:', error);
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
@ -165,89 +244,92 @@ function createStreamingLLM({
 | 
			
		||||
            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,
 | 
			
		||||
                        }
 | 
			
		||||
                    })
 | 
			
		||||
                });
 | 
			
		||||
            // 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);
 | 
			
		||||
                        }
 | 
			
		||||
                    if (!response.ok) {
 | 
			
		||||
                        throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
 | 
			
		||||
                    }
 | 
			
		||||
                });
 | 
			
		||||
 | 
			
		||||
                return {
 | 
			
		||||
                    ok: true,
 | 
			
		||||
                    body: stream
 | 
			
		||||
                };
 | 
			
		||||
                
 | 
			
		||||
            } catch (error) {
 | 
			
		||||
                console.error('[Ollama Provider] Request error:', error);
 | 
			
		||||
                throw error;
 | 
			
		||||
            }
 | 
			
		||||
                    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;
 | 
			
		||||
                }
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    };
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -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();
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,20 +1,42 @@
 | 
			
		||||
const sqliteClient = require('../../services/sqliteClient');
 | 
			
		||||
const encryptionService = require('../../services/encryptionService');
 | 
			
		||||
 | 
			
		||||
function getByProvider(uid, provider) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
 | 
			
		||||
    return stmt.get(uid, provider) || null;
 | 
			
		||||
    const result = stmt.get(uid, provider) || null;
 | 
			
		||||
    
 | 
			
		||||
    if (result && result.api_key) {
 | 
			
		||||
        // Decrypt API key if it exists
 | 
			
		||||
        result.api_key = encryptionService.decrypt(result.api_key);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    return result;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function getAllByUid(uid) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
 | 
			
		||||
    return stmt.all(uid);
 | 
			
		||||
    const results = stmt.all(uid);
 | 
			
		||||
    
 | 
			
		||||
    // Decrypt API keys for all results
 | 
			
		||||
    return results.map(result => {
 | 
			
		||||
        if (result.api_key) {
 | 
			
		||||
            result.api_key = encryptionService.decrypt(result.api_key);
 | 
			
		||||
        }
 | 
			
		||||
        return result;
 | 
			
		||||
    });
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function upsert(uid, provider, settings) {
 | 
			
		||||
    const db = sqliteClient.getDb();
 | 
			
		||||
    
 | 
			
		||||
    // Encrypt API key if it exists
 | 
			
		||||
    const encryptedSettings = { ...settings };
 | 
			
		||||
    if (encryptedSettings.api_key) {
 | 
			
		||||
        encryptedSettings.api_key = encryptionService.encrypt(encryptedSettings.api_key);
 | 
			
		||||
    }
 | 
			
		||||
    
 | 
			
		||||
    // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
 | 
			
		||||
    const stmt = db.prepare(`
 | 
			
		||||
        INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at)
 | 
			
		||||
@ -29,11 +51,11 @@ function upsert(uid, provider, settings) {
 | 
			
		||||
    const result = stmt.run(
 | 
			
		||||
        uid,
 | 
			
		||||
        provider,
 | 
			
		||||
        settings.api_key || null,
 | 
			
		||||
        settings.selected_llm_model || null,
 | 
			
		||||
        settings.selected_stt_model || null,
 | 
			
		||||
        settings.created_at || Date.now(),
 | 
			
		||||
        settings.updated_at
 | 
			
		||||
        encryptedSettings.api_key || null,
 | 
			
		||||
        encryptedSettings.selected_llm_model || null,
 | 
			
		||||
        encryptedSettings.selected_stt_model || null,
 | 
			
		||||
        encryptedSettings.created_at || Date.now(),
 | 
			
		||||
        encryptedSettings.updated_at
 | 
			
		||||
    );
 | 
			
		||||
    
 | 
			
		||||
    return { changes: result.changes };
 | 
			
		||||
 | 
			
		||||
@ -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;
 | 
			
		||||
                }
 | 
			
		||||
                
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,7 @@
 | 
			
		||||
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');
 | 
			
		||||
@ -9,8 +10,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 = {};
 | 
			
		||||
@ -21,6 +23,19 @@ 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();
 | 
			
		||||
@ -143,17 +158,8 @@ class ModelStateService {
 | 
			
		||||
            
 | 
			
		||||
            for (const setting of providerSettings) {
 | 
			
		||||
                if (setting.api_key) {
 | 
			
		||||
                    // API keys are stored encrypted in database, decrypt them
 | 
			
		||||
                    if (setting.provider !== 'ollama' && setting.provider !== 'whisper') {
 | 
			
		||||
                        try {
 | 
			
		||||
                            apiKeys[setting.provider] = encryptionService.decrypt(setting.api_key);
 | 
			
		||||
                        } catch (error) {
 | 
			
		||||
                            console.error(`[ModelStateService] Failed to decrypt API key for ${setting.provider}, resetting`);
 | 
			
		||||
                            apiKeys[setting.provider] = null;
 | 
			
		||||
                        }
 | 
			
		||||
                    } else {
 | 
			
		||||
                        apiKeys[setting.provider] = setting.api_key;
 | 
			
		||||
                    }
 | 
			
		||||
                    // API keys are already decrypted by the repository layer
 | 
			
		||||
                    apiKeys[setting.provider] = setting.api_key;
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
            
 | 
			
		||||
@ -171,6 +177,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
 | 
			
		||||
@ -217,12 +226,9 @@ class ModelStateService {
 | 
			
		||||
            // Save provider settings (API keys)
 | 
			
		||||
            for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) {
 | 
			
		||||
                if (apiKey) {
 | 
			
		||||
                    const encryptedKey = (provider !== 'ollama' && provider !== 'whisper') 
 | 
			
		||||
                        ? encryptionService.encrypt(apiKey)
 | 
			
		||||
                        : apiKey;
 | 
			
		||||
                        
 | 
			
		||||
                    // API keys will be encrypted by the repository layer
 | 
			
		||||
                    await providerSettingsRepository.upsert(provider, {
 | 
			
		||||
                        api_key: encryptedKey
 | 
			
		||||
                        api_key: apiKey
 | 
			
		||||
                    });
 | 
			
		||||
                } else {
 | 
			
		||||
                    // Remove empty API keys
 | 
			
		||||
@ -262,7 +268,7 @@ class ModelStateService {
 | 
			
		||||
        };
 | 
			
		||||
        
 | 
			
		||||
        for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
 | 
			
		||||
            if (key && provider !== 'ollama' && provider !== 'whisper') {
 | 
			
		||||
            if (key) {
 | 
			
		||||
                try {
 | 
			
		||||
                    stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
 | 
			
		||||
                } catch (error) {
 | 
			
		||||
@ -331,22 +337,19 @@ 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;
 | 
			
		||||
        
 | 
			
		||||
        // API keys will be encrypted by the repository layer
 | 
			
		||||
        this.state.apiKeys[provider] = key;
 | 
			
		||||
        await this._saveState();
 | 
			
		||||
        
 | 
			
		||||
        this._autoSelectAvailableModels([]);
 | 
			
		||||
        
 | 
			
		||||
        this._broadcastToAllWindows('model-state:updated', this.state);
 | 
			
		||||
        this._broadcastToAllWindows('settings-updated');
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    getApiKey(provider) {
 | 
			
		||||
@ -358,19 +361,14 @@ class ModelStateService {
 | 
			
		||||
        return displayKeys;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    removeApiKey(provider) {
 | 
			
		||||
        console.log(`[ModelStateService] Removing API key for provider: ${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;
 | 
			
		||||
@ -456,11 +454,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 +492,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;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    /**
 | 
			
		||||
@ -493,7 +527,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');
 | 
			
		||||
@ -509,12 +543,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
 | 
			
		||||
            
 | 
			
		||||
@ -544,13 +578,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._broadcastToAllWindows('force-show-apikey-header');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return success;
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@ 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');
 | 
			
		||||
@ -27,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
 | 
			
		||||
@ -40,6 +40,19 @@ 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();
 | 
			
		||||
@ -87,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,
 | 
			
		||||
@ -115,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);
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -377,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'
 | 
			
		||||
@ -388,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;
 | 
			
		||||
@ -406,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);
 | 
			
		||||
@ -639,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;
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -671,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) {
 | 
			
		||||
@ -844,10 +896,10 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleInstall(event) {
 | 
			
		||||
    async handleInstall() {
 | 
			
		||||
        try {
 | 
			
		||||
            const onProgress = (data) => {
 | 
			
		||||
                event.sender.send('ollama:install-progress', data);
 | 
			
		||||
                this._broadcastToAllWindows('ollama:install-progress', data);
 | 
			
		||||
            };
 | 
			
		||||
 | 
			
		||||
            await this.autoInstall(onProgress);
 | 
			
		||||
@ -857,26 +909,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._broadcastToAllWindows('ollama: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._broadcastToAllWindows('ollama: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 +966,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 +981,7 @@ class OllamaService extends LocalAIServiceBase {
 | 
			
		||||
        } catch (error) {
 | 
			
		||||
            console.error('[OllamaService] Failed to pull model:', error);
 | 
			
		||||
            await ollamaModelRepository.updateInstallStatus(modelName, false, false);
 | 
			
		||||
            this._broadcastToAllWindows('ollama:pull-error', { model: modelName, error: error.message });
 | 
			
		||||
            return { success: false, error: error.message };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -990,7 +1026,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);
 | 
			
		||||
 | 
			
		||||
@ -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,19 +171,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._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(event, modelId) {
 | 
			
		||||
    async handleDownloadModel(modelId) {
 | 
			
		||||
        try {
 | 
			
		||||
            console.log(`[WhisperService] Handling download for model: ${modelId}`);
 | 
			
		||||
 | 
			
		||||
@ -177,19 +193,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) {
 | 
			
		||||
 | 
			
		||||
@ -41,11 +41,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; 
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    initialize() {
 | 
			
		||||
 | 
			
		||||
@ -35,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 };
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async handleSendSystemAudioContent(data, mimeType) {
 | 
			
		||||
 | 
			
		||||
@ -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) {
 | 
			
		||||
@ -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');
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -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);
 | 
			
		||||
 | 
			
		||||
@ -532,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.');
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -136,6 +136,9 @@ contextBridge.exposeInMainWorld('api', {
 | 
			
		||||
    // 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),
 | 
			
		||||
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@ -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,14 +25,25 @@ 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') {
 | 
			
		||||
@ -49,6 +62,10 @@ class HeaderTransitionManager {
 | 
			
		||||
 | 
			
		||||
        console.log('[HeaderController] Manager initialized');
 | 
			
		||||
 | 
			
		||||
        // WelcomeHeader 콜백 메서드들
 | 
			
		||||
        this.handleLoginOption = this.handleLoginOption.bind(this);
 | 
			
		||||
        this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
 | 
			
		||||
 | 
			
		||||
        this._bootstrap();
 | 
			
		||||
 | 
			
		||||
        if (window.api) {
 | 
			
		||||
@ -66,8 +83,14 @@ class HeaderTransitionManager {
 | 
			
		||||
            });
 | 
			
		||||
            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');
 | 
			
		||||
                }
 | 
			
		||||
            });            
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
@ -88,7 +111,7 @@ class HeaderTransitionManager {
 | 
			
		||||
            this.handleStateUpdate(userState);
 | 
			
		||||
        } else {
 | 
			
		||||
            // Fallback for non-electron environment (testing/web)
 | 
			
		||||
            this.ensureHeader('apikey');
 | 
			
		||||
            this.ensureHeader('welcome');
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -110,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() {
 | 
			
		||||
@ -161,15 +212,13 @@ class HeaderTransitionManager {
 | 
			
		||||
    async _resizeForMain() {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        console.log('[HeaderController] _resizeForMain: Resizing window to 353x47');
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }).catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForApiKey() {
 | 
			
		||||
    async _resizeForApiKey(height = 370) {
 | 
			
		||||
        if (!window.api) return;
 | 
			
		||||
        console.log('[HeaderController] _resizeForApiKey: Resizing window to 350x300');
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 350, height: 300 })
 | 
			
		||||
            .catch(() => {});
 | 
			
		||||
        console.log(`[HeaderController] _resizeForApiKey: Resizing window to 456x${height}`);
 | 
			
		||||
        return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    async _resizeForPermissionHeader() {
 | 
			
		||||
@ -178,6 +227,13 @@ class HeaderTransitionManager {
 | 
			
		||||
            .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.api) {
 | 
			
		||||
            return { success: true };
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										234
									
								
								src/ui/app/WelcomeHeader.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										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,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
 | 
			
		||||
@ -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);
 | 
			
		||||
            });
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user