minor update + merge

This commit is contained in:
jhyang0 2025-07-14 02:09:31 +09:00
parent 8da13dcb27
commit 290ee0ed29
21 changed files with 2999 additions and 2211 deletions

View File

@ -6,15 +6,15 @@ const whisperService = require('../features/common/services/whisperService');
const ollamaService = require('../features/common/services/ollamaService'); const ollamaService = require('../features/common/services/ollamaService');
const modelStateService = require('../features/common/services/modelStateService'); const modelStateService = require('../features/common/services/modelStateService');
const shortcutsService = require('../features/shortcuts/shortcutsService'); const shortcutsService = require('../features/shortcuts/shortcutsService');
const presetRepository = require('../features/common/repositories/preset');
const askService = require('../features/ask/askService'); const askService = require('../features/ask/askService');
const listenService = require('../features/listen/listenService'); const listenService = require('../features/listen/listenService');
const permissionService = require('../features/common/services/permissionService'); const permissionService = require('../features/common/services/permissionService');
module.exports = { module.exports = {
// Renderer로부터의 요청을 수신 // Renderer로부터의 요청을 수신하고 서비스로 전달
initialize() { initialize() {
// Settings Service // Settings Service
ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets()); ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting()); ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
@ -33,7 +33,6 @@ module.exports = {
ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults()); ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults());
ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds)); ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
// Permissions // Permissions
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission()); ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
@ -41,7 +40,6 @@ module.exports = {
ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted()); ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted()); ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
// User/Auth // User/Auth
ipcMain.handle('get-current-user', () => authService.getCurrentUser()); ipcMain.handle('get-current-user', () => authService.getCurrentUser());
ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow()); ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow());
@ -51,7 +49,7 @@ module.exports = {
ipcMain.handle('quit-application', () => app.quit()); ipcMain.handle('quit-application', () => app.quit());
// Whisper // 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()); ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
// General // General
@ -60,17 +58,17 @@ module.exports = {
// Ollama // Ollama
ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus()); ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus());
ipcMain.handle('ollama:install', async (event) => await ollamaService.handleInstall(event)); ipcMain.handle('ollama:install', async () => await ollamaService.handleInstall());
ipcMain.handle('ollama:start-service', async (event) => await ollamaService.handleStartService(event)); ipcMain.handle('ollama:start-service', async () => await ollamaService.handleStartService());
ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady()); ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady());
ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels()); ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels());
ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions()); 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: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:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName));
ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp()); ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp());
ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus()); 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 // Ask
ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt)); ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt));
@ -101,8 +99,6 @@ module.exports = {
} }
}); });
// ModelStateService // ModelStateService
ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); 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:get-all-keys', () => modelStateService.getAllApiKeys());
@ -114,8 +110,6 @@ module.exports = {
ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
console.log('[FeatureBridge] Initialized with all feature handlers.'); console.log('[FeatureBridge] Initialized with all feature handlers.');
}, },

View File

@ -12,6 +12,7 @@ module.exports = {
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow()); ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow()); ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
ipcMain.handle('open-login-page', () => windowManager.openLoginPage()); ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction)); ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings')); ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));

View File

@ -281,6 +281,7 @@ class AskService {
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined, portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
}); });
try {
const response = await streamingLLM.streamChat(messages); const response = await streamingLLM.streamChat(messages);
const askWin = getWindowPool()?.get('ask'); const askWin = getWindowPool()?.get('ask');
@ -297,19 +298,61 @@ class AskService {
}); });
await this._processStream(reader, askWin, sessionId, signal); await this._processStream(reader, askWin, sessionId, signal);
return { success: true }; return { success: true };
} catch (error) { } catch (multimodalError) {
if (error.name === 'AbortError') { // 멀티모달 요청이 실패했고 스크린샷이 포함되어 있다면 텍스트만으로 재시도
console.log('[AskService] SendMessage operation was successfully aborted.'); if (screenshotBase64 && this._isMultimodalError(multimodalError)) {
return { success: true, response: 'Cancelled' }; 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.' };
} }
console.error('[AskService] Error processing message:', error); const fallbackReader = fallbackResponse.body.getReader();
this.state.isLoading = false; signal.addEventListener('abort', () => {
this.state.error = error.message; console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`);
fallbackReader.cancel(signal.reason).catch(() => {});
});
await this._processStream(fallbackReader, askWin, sessionId, signal);
return { success: true };
} else {
// 다른 종류의 에러이거나 스크린샷이 없었다면 그대로 throw
throw multimodalError;
}
}
} catch (error) {
console.error('[AskService] Error during message processing:', error);
this.state = {
...this.state,
isLoading: false,
isStreaming: false,
showTextInput: true,
};
this._broadcastState(); this._broadcastState();
const askWin = getWindowPool()?.get('ask');
if (askWin && !askWin.isDestroyed()) {
const streamError = error.message || 'Unknown error occurred';
askWin.webContents.send('ask-response-stream-error', { error: streamError });
}
return { success: false, error: error.message }; 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(); const askService = new AskService();

View File

@ -68,7 +68,8 @@ const PROVIDERS = {
handler: () => { handler: () => {
// This needs to remain a function due to its conditional logic for renderer/main process // This needs to remain a function due to its conditional logic for renderer/main process
if (typeof window === 'undefined') { 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 a dummy object for the renderer process
return { return {

View File

@ -1,6 +1,79 @@
const http = require('http'); const http = require('http');
const fetch = require('node-fetch'); 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 { class OllamaProvider {
static async validateApiKey() { static async validateApiKey() {
try { try {
@ -79,6 +152,8 @@ function createLLM({
} }
messages.push({ role: 'user', content: userContent.join('\n') }); messages.push({ role: 'user', content: userContent.join('\n') });
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try { try {
const response = await fetch(`${baseUrl}/api/chat`, { const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST', method: 'POST',
@ -110,11 +185,14 @@ function createLLM({
console.error('Ollama LLM error:', error); console.error('Ollama LLM error:', error);
throw error; throw error;
} }
});
}, },
chat: async (messages) => { chat: async (messages) => {
const ollamaMessages = convertMessagesToOllamaFormat(messages); const ollamaMessages = convertMessagesToOllamaFormat(messages);
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try { try {
const response = await fetch(`${baseUrl}/api/chat`, { const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST', method: 'POST',
@ -144,6 +222,7 @@ function createLLM({
console.error('Ollama chat error:', error); console.error('Ollama chat error:', error);
throw error; throw error;
} }
});
} }
}; };
} }
@ -165,6 +244,8 @@ function createStreamingLLM({
const ollamaMessages = convertMessagesToOllamaFormat(messages); const ollamaMessages = convertMessagesToOllamaFormat(messages);
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages); console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
// Streaming requests have priority over queued requests
return await requestQueue.addStreamingRequest(async () => {
try { try {
const response = await fetch(`${baseUrl}/api/chat`, { const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST', method: 'POST',
@ -248,6 +329,7 @@ function createStreamingLLM({
console.error('[Ollama Provider] Request error:', error); console.error('[Ollama Provider] Request error:', error);
throw error; throw error;
} }
});
} }
}; };
} }

View File

@ -184,11 +184,12 @@ class WhisperProvider {
async initialize() { async initialize() {
if (!this.whisperService) { if (!this.whisperService) {
const { WhisperService } = require('../../services/whisperService'); this.whisperService = require('../../services/whisperService');
this.whisperService = new WhisperService(); if (!this.whisperService.isInitialized) {
await this.whisperService.initialize(); await this.whisperService.initialize();
} }
} }
}
async createSTT(config) { async createSTT(config) {
await this.initialize(); await this.initialize();

View File

@ -1,20 +1,42 @@
const sqliteClient = require('../../services/sqliteClient'); const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(uid, provider) { function getByProvider(uid, provider) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?'); 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) { function getAllByUid(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider'); 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) { function upsert(uid, provider, settings) {
const db = sqliteClient.getDb(); 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) // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at) 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( const result = stmt.run(
uid, uid,
provider, provider,
settings.api_key || null, encryptedSettings.api_key || null,
settings.selected_llm_model || null, encryptedSettings.selected_llm_model || null,
settings.selected_stt_model || null, encryptedSettings.selected_stt_model || null,
settings.created_at || Date.now(), encryptedSettings.created_at || Date.now(),
settings.updated_at encryptedSettings.updated_at
); );
return { changes: result.changes }; return { changes: result.changes };

View File

@ -1,6 +1,7 @@
const { exec } = require('child_process'); const { exec } = require('child_process');
const { promisify } = require('util'); const { promisify } = require('util');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const { BrowserWindow } = require('electron');
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const https = require('https'); const https = require('https');
@ -17,6 +18,19 @@ class LocalAIServiceBase extends EventEmitter {
this.installationProgress = new Map(); 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() { getPlatform() {
return process.platform; return process.platform;
} }
@ -65,7 +79,7 @@ class LocalAIServiceBase extends EventEmitter {
setInstallProgress(modelName, progress) { setInstallProgress(modelName, progress) {
this.installationProgress.set(modelName, progress); this.installationProgress.set(modelName, progress);
this.emit('install-progress', { model: modelName, progress }); // 각 서비스에서 직접 브로드캐스트하도록 변경
} }
clearInstallProgress(modelName) { clearInstallProgress(modelName) {
@ -152,7 +166,8 @@ class LocalAIServiceBase extends EventEmitter {
const { const {
onProgress = null, onProgress = null,
headers = { 'User-Agent': 'Glass-App' }, headers = { 'User-Agent': 'Glass-App' },
timeout = 300000 // 5 minutes default timeout = 300000, // 5 minutes default
modelId = null // 모델 ID를 위한 추가 옵션
} = options; } = options;
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -190,17 +205,23 @@ class LocalAIServiceBase extends EventEmitter {
response.on('data', (chunk) => { response.on('data', (chunk) => {
downloadedSize += chunk.length; downloadedSize += chunk.length;
if (onProgress && totalSize > 0) { if (totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100); const progress = Math.round((downloadedSize / totalSize) * 100);
// 이벤트 기반 진행률 보고는 각 서비스에서 직접 처리
// 기존 콜백 지원 (호환성 유지)
if (onProgress) {
onProgress(progress, downloadedSize, totalSize); onProgress(progress, downloadedSize, totalSize);
} }
}
}); });
response.pipe(file); response.pipe(file);
file.on('finish', () => { file.on('finish', () => {
file.close(() => { file.close(() => {
this.emit('download-complete', { url, destination, size: downloadedSize }); // download-complete 이벤트는 각 서비스에서 직접 처리
resolve({ success: true, size: downloadedSize }); resolve({ success: true, size: downloadedSize });
}); });
}); });
@ -216,7 +237,7 @@ class LocalAIServiceBase extends EventEmitter {
request.on('error', (err) => { request.on('error', (err) => {
file.close(); file.close();
fs.unlink(destination, () => {}); fs.unlink(destination, () => {});
this.emit('download-error', { url, error: err }); this.emit('download-error', { url, error: err, modelId });
reject(err); reject(err);
}); });
@ -230,11 +251,20 @@ class LocalAIServiceBase extends EventEmitter {
} }
async downloadWithRetry(url, destination, options = {}) { 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++) { for (let attempt = 1; attempt <= maxRetries; attempt++) {
try { try {
const result = await this.downloadFile(url, destination, downloadOptions); const result = await this.downloadFile(url, destination, {
...downloadOptions,
modelId
});
if (expectedChecksum) { if (expectedChecksum) {
const isValid = await this.verifyChecksum(destination, expectedChecksum); const isValid = await this.verifyChecksum(destination, expectedChecksum);
@ -248,6 +278,7 @@ class LocalAIServiceBase extends EventEmitter {
return result; return result;
} catch (error) { } catch (error) {
if (attempt === maxRetries) { if (attempt === maxRetries) {
// download-error 이벤트는 각 서비스에서 직접 처리
throw error; throw error;
} }

View File

@ -1,6 +1,7 @@
const Store = require('electron-store'); const Store = require('electron-store');
const fetch = require('node-fetch'); 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 { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService'); const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings'); const providerSettingsRepository = require('../repositories/providerSettings');
@ -9,8 +10,9 @@ const userModelSelectionsRepository = require('../repositories/userModelSelectio
// Import authService directly (singleton) // Import authService directly (singleton)
const authService = require('./authService'); const authService = require('./authService');
class ModelStateService { class ModelStateService extends EventEmitter {
constructor() { constructor() {
super();
this.authService = authService; this.authService = authService;
this.store = new Store({ name: 'pickle-glass-model-state' }); this.store = new Store({ name: 'pickle-glass-model-state' });
this.state = {}; this.state = {};
@ -21,6 +23,19 @@ class ModelStateService {
userModelSelectionsRepository.setAuthService(authService); 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() { async initialize() {
console.log('[ModelStateService] Initializing...'); console.log('[ModelStateService] Initializing...');
await this._loadStateForCurrentUser(); await this._loadStateForCurrentUser();
@ -143,19 +158,10 @@ class ModelStateService {
for (const setting of providerSettings) { for (const setting of providerSettings) {
if (setting.api_key) { if (setting.api_key) {
// API keys are stored encrypted in database, decrypt them // API keys are already decrypted by the repository layer
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; apiKeys[setting.provider] = setting.api_key;
} }
} }
}
// Load global model selections // Load global model selections
const modelSelections = await userModelSelectionsRepository.get(); const modelSelections = await userModelSelectionsRepository.get();
@ -171,6 +177,9 @@ class ModelStateService {
console.log(`[ModelStateService] State loaded from database for user: ${userId}`); console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
// Auto-select available models after loading state
this._autoSelectAvailableModels();
} catch (error) { } catch (error) {
console.error('[ModelStateService] Failed to load state from database:', error); console.error('[ModelStateService] Failed to load state from database:', error);
// Fall back to default state // Fall back to default state
@ -217,12 +226,9 @@ class ModelStateService {
// Save provider settings (API keys) // Save provider settings (API keys)
for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) { for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) {
if (apiKey) { if (apiKey) {
const encryptedKey = (provider !== 'ollama' && provider !== 'whisper') // API keys will be encrypted by the repository layer
? encryptionService.encrypt(apiKey)
: apiKey;
await providerSettingsRepository.upsert(provider, { await providerSettingsRepository.upsert(provider, {
api_key: encryptedKey api_key: apiKey
}); });
} else { } else {
// Remove empty API keys // Remove empty API keys
@ -262,7 +268,7 @@ class ModelStateService {
}; };
for (const [provider, key] of Object.entries(stateToSave.apiKeys)) { for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
if (key && provider !== 'ollama' && provider !== 'whisper') { if (key) {
try { try {
stateToSave.apiKeys[provider] = encryptionService.encrypt(key); stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
} catch (error) { } catch (error) {
@ -331,22 +337,19 @@ class ModelStateService {
} }
async setApiKey(provider, key) { async setApiKey(provider, key) {
if (provider in this.state.apiKeys) { console.log(`[ModelStateService] setApiKey: ${provider}`);
if (!provider) {
throw new Error('Provider is required');
}
// API keys will be encrypted by the repository layer
this.state.apiKeys[provider] = key; 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(); await this._saveState();
return true;
} this._autoSelectAvailableModels([]);
return false;
this._broadcastToAllWindows('model-state:updated', this.state);
this._broadcastToAllWindows('settings-updated');
} }
getApiKey(provider) { getApiKey(provider) {
@ -358,19 +361,14 @@ class ModelStateService {
return displayKeys; return displayKeys;
} }
removeApiKey(provider) { async removeApiKey(provider) {
console.log(`[ModelStateService] Removing API key for provider: ${provider}`); if (this.state.apiKeys[provider]) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = null; this.state.apiKeys[provider] = null;
const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm); await providerSettingsRepository.remove(provider);
if (llmProvider === provider) this.state.selectedModels.llm = null; await this._saveState();
this._autoSelectAvailableModels([]);
const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt); this._broadcastToAllWindows('model-state:updated', this.state);
if (sttProvider === provider) this.state.selectedModels.stt = null; this._broadcastToAllWindows('settings-updated');
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
return true; return true;
} }
return false; return false;
@ -456,11 +454,36 @@ class ModelStateService {
const available = []; const available = [];
const modelList = type === 'llm' ? 'llmModels' : 'sttModels'; const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
Object.entries(this.state.apiKeys).forEach(([providerId, key]) => { for (const [providerId, key] of Object.entries(this.state.apiKeys)) {
if (key && PROVIDERS[providerId]?.[modelList]) { 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]); 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()]; return [...new Map(available.map(item => [item.id, item])).values()];
} }
@ -469,21 +492,32 @@ class ModelStateService {
} }
setSelectedModel(type, modelId) { setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(type, modelId); const availableModels = this.getAvailableModels(type);
if (provider && this.state.apiKeys[provider]) { const isAvailable = availableModels.some(model => model.id === modelId);
const previousModel = this.state.selectedModels[type];
if (!isAvailable) {
console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`);
return false;
}
const previousModelId = this.state.selectedModels[type];
this.state.selectedModels[type] = modelId; this.state.selectedModels[type] = modelId;
this._saveState(); this._saveState();
// Auto warm-up for Ollama LLM models when changed console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`);
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
this._autoWarmUpOllamaModel(modelId, previousModel); // 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; return true;
} }
return false;
}
/** /**
* Auto warm-up Ollama model when LLM selection changes * Auto warm-up Ollama model when LLM selection changes
@ -493,7 +527,7 @@ class ModelStateService {
*/ */
async _autoWarmUpOllamaModel(newModelId, previousModelId) { async _autoWarmUpOllamaModel(newModelId, previousModelId) {
try { 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 // Get Ollama service if available
const ollamaService = require('./ollamaService'); const ollamaService = require('./ollamaService');
@ -509,12 +543,12 @@ class ModelStateService {
const success = await ollamaService.warmUpModel(newModelId); const success = await ollamaService.warmUpModel(newModelId);
if (success) { if (success) {
console.log(`[ModelStateService] Successfully warmed up model: ${newModelId}`); console.log(`[ModelStateService] Successfully warmed up model: ${newModelId}`);
} else { } else {
console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`); console.log(`[ModelStateService] Failed to warm up model: ${newModelId}`);
} }
} catch (error) { } 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 }, 500); // 500ms delay
@ -544,13 +578,11 @@ class ModelStateService {
async handleRemoveApiKey(provider) { async handleRemoveApiKey(provider) {
console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`); console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`);
const success = this.removeApiKey(provider); const success = await this.removeApiKey(provider);
if (success) { if (success) {
const selectedModels = this.getSelectedModels(); const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) { if (!selectedModels.llm || !selectedModels.stt) {
webContents.getAllWebContents().forEach(wc => { this._broadcastToAllWindows('force-show-apikey-header');
wc.send('force-show-apikey-header');
});
} }
} }
return success; return success;

View File

@ -3,7 +3,7 @@ const { promisify } = require('util');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const path = require('path'); const path = require('path');
const fs = require('fs').promises; const fs = require('fs').promises;
const { app } = require('electron'); const { app, BrowserWindow } = require('electron');
const LocalAIServiceBase = require('./localAIServiceBase'); const LocalAIServiceBase = require('./localAIServiceBase');
const { spawnAsync } = require('../utils/spawnHelper'); const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums'); const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
@ -27,8 +27,8 @@ class OllamaService extends LocalAIServiceBase {
}; };
// Configuration // Configuration
this.requestTimeout = 8000; // 8s for health checks this.requestTimeout = 0; // Delete timeout
this.warmupTimeout = 15000; // 15s for model warmup this.warmupTimeout = 120000; // 120s for model warmup
this.healthCheckInterval = 60000; // 1min between health checks this.healthCheckInterval = 60000; // 1min between health checks
this.circuitBreakerThreshold = 3; this.circuitBreakerThreshold = 3;
this.circuitBreakerCooldown = 30000; // 30s this.circuitBreakerCooldown = 30000; // 30s
@ -40,6 +40,19 @@ class OllamaService extends LocalAIServiceBase {
this._startHealthMonitoring(); 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() { async getStatus() {
try { try {
const installed = await this.isInstalled(); const installed = await this.isInstalled();
@ -87,14 +100,17 @@ class OllamaService extends LocalAIServiceBase {
const controller = new AbortController(); const controller = new AbortController();
const timeout = options.timeout || this.requestTimeout; const timeout = options.timeout || this.requestTimeout;
// Set up timeout mechanism // Set up timeout mechanism only if timeout > 0
const timeoutId = setTimeout(() => { let timeoutId = null;
if (timeout > 0) {
timeoutId = setTimeout(() => {
controller.abort(); controller.abort();
this.activeRequests.delete(requestId); this.activeRequests.delete(requestId);
this._recordFailure(); this._recordFailure();
}, timeout); }, timeout);
this.requestTimeouts.set(requestId, timeoutId); this.requestTimeouts.set(requestId, timeoutId);
}
const requestPromise = this._executeRequest(url, { const requestPromise = this._executeRequest(url, {
...options, ...options,
@ -115,8 +131,10 @@ class OllamaService extends LocalAIServiceBase {
} }
throw error; throw error;
} finally { } finally {
if (timeoutId !== null) {
clearTimeout(timeoutId); clearTimeout(timeoutId);
this.requestTimeouts.delete(requestId); this.requestTimeouts.delete(requestId);
}
this.activeRequests.delete(operationType === 'health' ? 'health' : requestId); this.activeRequests.delete(operationType === 'health' ? 'health' : requestId);
} }
} }
@ -377,7 +395,7 @@ class OllamaService extends LocalAIServiceBase {
if (progress !== null) { if (progress !== null) {
this.setInstallProgress(modelName, progress); this.setInstallProgress(modelName, progress);
this.emit('pull-progress', { this._broadcastToAllWindows('ollama:pull-progress', {
model: modelName, model: modelName,
progress, progress,
status: data.status || 'downloading' status: data.status || 'downloading'
@ -388,7 +406,7 @@ class OllamaService extends LocalAIServiceBase {
// Handle completion // Handle completion
if (data.status === 'success') { if (data.status === 'success') {
console.log(`[OllamaService] Successfully pulled model: ${modelName}`); console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
this.emit('pull-complete', { model: modelName }); this._broadcastToAllWindows('ollama:pull-complete', { model: modelName });
this.clearInstallProgress(modelName); this.clearInstallProgress(modelName);
resolve(); resolve();
return; return;
@ -406,7 +424,7 @@ class OllamaService extends LocalAIServiceBase {
const data = JSON.parse(buffer); const data = JSON.parse(buffer);
if (data.status === 'success') { if (data.status === 'success') {
console.log(`[OllamaService] Successfully pulled model: ${modelName}`); console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
this.emit('pull-complete', { model: modelName }); this._broadcastToAllWindows('ollama:pull-complete', { model: modelName });
} }
} catch (parseError) { } catch (parseError) {
console.warn('[OllamaService] Failed to parse final buffer:', buffer); console.warn('[OllamaService] Failed to parse final buffer:', buffer);
@ -639,10 +657,50 @@ class OllamaService extends LocalAIServiceBase {
return true; return true;
} catch (error) { } catch (error) {
// 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); console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
return false; return false;
} }
} }
}
async autoWarmUpSelectedModel() { async autoWarmUpSelectedModel() {
try { try {
@ -671,14 +729,8 @@ class OllamaService extends LocalAIServiceBase {
return false; return false;
} }
// Check if model is installed // 설치 여부 체크 제거 - _performWarmUp에서 자동으로 설치 처리
const isInstalled = await this.isModelInstalled(llmModelId); console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId} (will auto-install if needed)`);
if (!isInstalled) {
console.log(`[OllamaService] Model ${llmModelId} not installed, skipping warm-up`);
return false;
}
console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId}`);
return await this.warmUpModel(llmModelId); return await this.warmUpModel(llmModelId);
} catch (error) { } catch (error) {
@ -844,10 +896,10 @@ class OllamaService extends LocalAIServiceBase {
} }
} }
async handleInstall(event) { async handleInstall() {
try { try {
const onProgress = (data) => { const onProgress = (data) => {
event.sender.send('ollama:install-progress', data); this._broadcastToAllWindows('ollama:install-progress', data);
}; };
await this.autoInstall(onProgress); await this.autoInstall(onProgress);
@ -857,26 +909,26 @@ class OllamaService extends LocalAIServiceBase {
await this.startService(); await this.startService();
onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 }); 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 }; return { success: true };
} catch (error) { } catch (error) {
console.error('[OllamaService] Failed to install:', 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 }; return { success: false, error: error.message };
} }
} }
async handleStartService(event) { async handleStartService() {
try { try {
if (!await this.isServiceRunning()) { if (!await this.isServiceRunning()) {
console.log('[OllamaService] Starting Ollama service...'); console.log('[OllamaService] Starting Ollama service...');
await this.startService(); await this.startService();
} }
event.sender.send('ollama:install-complete', { success: true }); this.emit('install-complete', { success: true });
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[OllamaService] Failed to start service:', 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 }; return { success: false, error: error.message };
} }
} }
@ -914,29 +966,12 @@ class OllamaService extends LocalAIServiceBase {
} }
} }
async handlePullModel(event, modelName) { async handlePullModel(modelName) {
try { try {
console.log(`[OllamaService] Starting model pull: ${modelName}`); console.log(`[OllamaService] Starting model pull: ${modelName}`);
await ollamaModelRepository.updateInstallStatus(modelName, false, true); 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 this.pullModel(modelName);
await ollamaModelRepository.updateInstallStatus(modelName, true, false); await ollamaModelRepository.updateInstallStatus(modelName, true, false);
@ -946,6 +981,7 @@ class OllamaService extends LocalAIServiceBase {
} catch (error) { } catch (error) {
console.error('[OllamaService] Failed to pull model:', error); console.error('[OllamaService] Failed to pull model:', error);
await ollamaModelRepository.updateInstallStatus(modelName, false, false); await ollamaModelRepository.updateInstallStatus(modelName, false, false);
this._broadcastToAllWindows('ollama:pull-error', { model: modelName, error: error.message });
return { success: false, 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 { try {
console.log(`[OllamaService] Manual shutdown requested (force: ${force})`); console.log(`[OllamaService] Manual shutdown requested (force: ${force})`);
const success = await this.shutdown(force); const success = await this.shutdown(force);

View File

@ -2,6 +2,7 @@ const { spawn } = require('child_process');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const { BrowserWindow } = require('electron');
const LocalAIServiceBase = require('./localAIServiceBase'); const LocalAIServiceBase = require('./localAIServiceBase');
const { spawnAsync } = require('../utils/spawnHelper'); const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums'); 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() { async initialize() {
if (this.isInitialized) return; if (this.isInitialized) return;
@ -157,19 +171,21 @@ class WhisperService extends LocalAIServiceBase {
const modelPath = await this.getModelPath(modelId); const modelPath = await this.getModelPath(modelId);
const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[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, { await this.downloadWithRetry(modelInfo.url, modelPath, {
expectedChecksum: checksumInfo?.sha256, expectedChecksum: checksumInfo?.sha256,
modelId, // modelId를 전달하여 LocalAIServiceBase에서 이벤트 발생 시 사용
onProgress: (progress) => { onProgress: (progress) => {
this.emit('downloadProgress', { modelId, progress }); this._broadcastToAllWindows('whisper:download-progress', { modelId, progress });
} }
}); });
console.log(`[WhisperService] Model ${modelId} downloaded successfully`); console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
this._broadcastToAllWindows('whisper:download-complete', { modelId });
} }
async handleDownloadModel(event, modelId) { async handleDownloadModel(modelId) {
try { try {
console.log(`[WhisperService] Handling download for model: ${modelId}`); console.log(`[WhisperService] Handling download for model: ${modelId}`);
@ -177,19 +193,7 @@ class WhisperService extends LocalAIServiceBase {
await this.initialize(); 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); await this.ensureModelAvailable(modelId);
} finally {
this.removeListener('downloadProgress', progressHandler);
}
return { success: true }; return { success: true };
} catch (error) { } catch (error) {

View File

@ -41,11 +41,58 @@ class ListenService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { const { windowPool } = require('../../window/windowManager');
if (!win.isDestroyed()) { const listenWindow = windowPool?.get('listen');
win.webContents.send(channel, data);
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() { initialize() {

View File

@ -35,11 +35,24 @@ class SttService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { // Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
if (!win.isDestroyed()) { const { windowPool } = require('../../../window/windowManager');
win.webContents.send(channel, data); 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) { async handleSendSystemAudioContent(data, mimeType) {

View File

@ -28,11 +28,12 @@ class SummaryService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { const { windowPool } = require('../../../window/windowManager');
if (!win.isDestroyed()) { const listenWindow = windowPool?.get('listen');
win.webContents.send(channel, data);
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
} }
});
} }
addConversationTurn(speaker, text) { addConversationTurn(speaker, text) {
@ -304,12 +305,11 @@ Keep all points concise and build upon previous analysis if provided.`,
*/ */
async triggerAnalysisIfNeeded() { async triggerAnalysisIfNeeded() {
if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) { 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) const data = await this.makeOutlineAndRequests(this.conversationHistory);
.then(data => {
if (data) { if (data) {
console.log('📤 Sending structured data to renderer'); console.log('Sending structured data to renderer');
this.sendToRenderer('summary-update', data); this.sendToRenderer('summary-update', data);
// Notify callback // Notify callback
@ -317,12 +317,8 @@ Keep all points concise and build upon previous analysis if provided.`,
this.onAnalysisComplete(data); this.onAnalysisComplete(data);
} }
} else { } else {
console.log('No analysis data returned from non-blocking call'); console.log('No analysis data returned');
} }
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
} }
} }

View File

@ -27,13 +27,16 @@ const NOTIFICATION_CONFIG = {
// New facade functions for model state management // New facade functions for model state management
async function getModelSettings() { async function getModelSettings() {
try { try {
const [config, storedKeys, availableLlm, availableStt, selectedModels] = await Promise.all([ const [config, storedKeys, selectedModels] = await Promise.all([
modelStateService.getProviderConfig(), modelStateService.getProviderConfig(),
modelStateService.getAllApiKeys(), modelStateService.getAllApiKeys(),
modelStateService.getAvailableModels('llm'),
modelStateService.getAvailableModels('stt'),
modelStateService.getSelectedModels(), modelStateService.getSelectedModels(),
]); ]);
// 동기 함수들은 별도로 호출
const availableLlm = modelStateService.getAvailableModels('llm');
const availableStt = modelStateService.getAvailableModels('stt');
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } }; return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
} catch (error) { } catch (error) {
console.error('[SettingsService] Error getting model settings:', error); console.error('[SettingsService] Error getting model settings:', error);

View File

@ -532,6 +532,7 @@ async function handleFirebaseAuthCallback(params) {
}; };
// 1. Sync user data to local DB // 1. Sync user data to local DB
userRepository.setAuthService(authService);
userRepository.findOrCreate(firebaseUser); userRepository.findOrCreate(firebaseUser);
console.log('[Auth] User data synced with local DB.'); console.log('[Auth] User data synced with local DB.');

View File

@ -137,6 +137,9 @@ contextBridge.exposeInMainWorld('api', {
onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback), onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('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 // Listeners
onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback), onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback), removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback),

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,20 @@
import './MainHeader.js'; import './MainHeader.js';
import './ApiKeyHeader.js'; import './ApiKeyHeader.js';
import './PermissionHeader.js'; import './PermissionHeader.js';
import './WelcomeHeader.js';
class HeaderTransitionManager { class HeaderTransitionManager {
constructor() { constructor() {
this.headerContainer = document.getElementById('header-container'); 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.apiKeyHeader = null;
this.mainHeader = null; this.mainHeader = null;
this.permissionHeader = null; this.permissionHeader = null;
/** /**
* only one header window is allowed * only one header window is allowed
* @param {'apikey'|'main'|'permission'} type * @param {'welcome'|'apikey'|'main'|'permission'} type
*/ */
this.ensureHeader = (type) => { this.ensureHeader = (type) => {
console.log('[HeaderController] ensureHeader: Ensuring header of type:', type); console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
@ -23,14 +25,25 @@ class HeaderTransitionManager {
this.headerContainer.innerHTML = ''; this.headerContainer.innerHTML = '';
this.welcomeHeader = null;
this.apiKeyHeader = null; this.apiKeyHeader = null;
this.mainHeader = null; this.mainHeader = null;
this.permissionHeader = null; this.permissionHeader = null;
// Create new header element // 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 = document.createElement('apikey-header');
this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState); 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); this.headerContainer.appendChild(this.apiKeyHeader);
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.'); console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
} else if (type === 'permission') { } else if (type === 'permission') {
@ -49,6 +62,10 @@ class HeaderTransitionManager {
console.log('[HeaderController] Manager initialized'); console.log('[HeaderController] Manager initialized');
// WelcomeHeader 콜백 메서드들
this.handleLoginOption = this.handleLoginOption.bind(this);
this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
this._bootstrap(); this._bootstrap();
if (window.api) { if (window.api) {
@ -66,8 +83,14 @@ class HeaderTransitionManager {
}); });
window.api.headerController.onForceShowApiKeyHeader(async () => { window.api.headerController.onForceShowApiKeyHeader(async () => {
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.'); console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
if (!isConfigured) {
await this._resizeForWelcome();
this.ensureHeader('welcome');
} else {
await this._resizeForApiKey(); await this._resizeForApiKey();
this.ensureHeader('apikey'); this.ensureHeader('apikey');
}
}); });
} }
} }
@ -88,7 +111,7 @@ class HeaderTransitionManager {
this.handleStateUpdate(userState); this.handleStateUpdate(userState);
} else { } else {
// Fallback for non-electron environment (testing/web) // Fallback for non-electron environment (testing/web)
this.ensureHeader('apikey'); this.ensureHeader('welcome');
} }
} }
@ -110,10 +133,38 @@ class HeaderTransitionManager {
this.transitionToMainHeader(); this.transitionToMainHeader();
} }
} else { } else {
await this._resizeForApiKey(); // 프로바이더가 설정되지 않았으면 WelcomeHeader 먼저 표시
this.ensureHeader('apikey'); 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 //////// //////// after_modelStateService ////////
async transitionToPermissionHeader() { async transitionToPermissionHeader() {
@ -161,15 +212,13 @@ class HeaderTransitionManager {
async _resizeForMain() { async _resizeForMain() {
if (!window.api) return; if (!window.api) return;
console.log('[HeaderController] _resizeForMain: Resizing window to 353x47'); console.log('[HeaderController] _resizeForMain: Resizing window to 353x47');
return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }) return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }).catch(() => {});
.catch(() => {});
} }
async _resizeForApiKey() { async _resizeForApiKey(height = 370) {
if (!window.api) return; if (!window.api) return;
console.log('[HeaderController] _resizeForApiKey: Resizing window to 350x300'); console.log(`[HeaderController] _resizeForApiKey: Resizing window to 456x${height}`);
return window.api.headerController.resizeHeaderWindow({ width: 350, height: 300 }) return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
.catch(() => {});
} }
async _resizeForPermissionHeader() { async _resizeForPermissionHeader() {
@ -178,6 +227,13 @@ class HeaderTransitionManager {
.catch(() => {}); .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() { async checkPermissions() {
if (!window.api) { if (!window.api) {
return { success: true }; return { success: true };

234
src/ui/app/WelcomeHeader.js Normal file
View 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);

View File

@ -1,5 +1,5 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js'; 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 { export class SettingsView extends LitElement {
static styles = css` static styles = css`
@ -531,7 +531,6 @@ export class SettingsView extends LitElement {
this.ollamaStatus = { installed: false, running: false }; this.ollamaStatus = { installed: false, running: false };
this.ollamaModels = []; this.ollamaModels = [];
this.installingModels = {}; // { modelName: progress } this.installingModels = {}; // { modelName: progress }
this.progressTracker = getOllamaProgressTracker();
// Whisper related // Whisper related
this.whisperModels = []; this.whisperModels = [];
this.whisperProgressTracker = null; // Will be initialized when needed this.whisperProgressTracker = null; // Will be initialized when needed
@ -775,31 +774,42 @@ export class SettingsView extends LitElement {
} }
async installOllamaModel(modelName) { async installOllamaModel(modelName) {
// Mark as installing try {
// Ollama 모델 다운로드 시작
this.installingModels = { ...this.installingModels, [modelName]: 0 }; this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.requestUpdate(); this.requestUpdate();
try { // 진행률 이벤트 리스너 설정
// Use the clean progress tracker - no manual event management needed const progressHandler = (event, data) => {
const success = await this.progressTracker.installModel(modelName, (progress) => { if (data.modelId === modelName) {
this.installingModels = { ...this.installingModels, [modelName]: progress }; this.installingModels = { ...this.installingModels, [modelName]: data.progress };
this.requestUpdate(); this.requestUpdate();
}); }
};
if (success) { // 진행률 이벤트 리스너 등록
// Refresh status after installation 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.refreshOllamaStatus();
await this.refreshModelData(); await this.refreshModelData();
// Auto-select the model after installation
await this.selectModel('llm', modelName);
} else { } else {
alert(`Installation of ${modelName} was cancelled`); throw new Error(result.error || 'Installation failed');
}
} finally {
// 진행률 이벤트 리스너 제거
window.api.settingsView.removeOnOllamaPullProgress(progressHandler);
} }
} catch (error) { } catch (error) {
console.error(`[SettingsView] Error installing model ${modelName}:`, 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]; delete this.installingModels[modelName];
this.requestUpdate(); this.requestUpdate();
} }
@ -891,7 +901,7 @@ export class SettingsView extends LitElement {
const installingModels = Object.keys(this.installingModels); const installingModels = Object.keys(this.installingModels);
if (installingModels.length > 0) { if (installingModels.length > 0) {
installingModels.forEach(modelName => { installingModels.forEach(modelName => {
this.progressTracker.cancelInstallation(modelName); window.api.settingsView.cancelOllamaInstallation(modelName);
}); });
} }
} }