This commit is contained in:
sanio 2025-07-14 02:24:03 +09:00
commit fbe5c22aa4
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 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,7 +33,6 @@ 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());
@ -41,7 +40,6 @@ module.exports = {
ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
// User/Auth
ipcMain.handle('get-current-user', () => authService.getCurrentUser());
ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow());
@ -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.');
},

View File

@ -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'));

View File

@ -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();

View File

@ -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 {

View File

@ -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
};
console.log('[Ollama Provider] Got streaming response');
} catch (error) {
console.error('[Ollama Provider] Request error:', error);
throw error;
}
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;
}
});
}
};
}

View File

@ -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();
}
}
}

View File

@ -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 };

View File

@ -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;
}

View File

@ -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();
const availableModels = this.getAvailableModels(type);
const isAvailable = availableModels.some(model => model.id === modelId);
// Auto warm-up for Ollama LLM models when changed
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
this._autoWarmUpOllamaModel(modelId, previousModel);
}
return true;
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;

View File

@ -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);
// 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);
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);

View File

@ -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) {

View File

@ -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() {

View File

@ -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) {

View File

@ -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);
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 from non-blocking call');
}
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
// Notify callback
if (this.onAnalysisComplete) {
this.onAnalysisComplete(data);
}
} else {
console.log('No analysis data returned');
}
}
}

View File

@ -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);

View File

@ -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.');

View File

@ -137,6 +137,9 @@ contextBridge.exposeInMainWorld('api', {
onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback),
onAskStreamError: (callback) => ipcRenderer.on('ask-response-stream-error', callback),
removeOnAskStreamError: (callback) => ipcRenderer.removeListener('ask-response-stream-error', callback),
// Listeners
onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback),

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,20 @@
import './MainHeader.js';
import './ApiKeyHeader.js';
import './PermissionHeader.js';
import './WelcomeHeader.js';
class HeaderTransitionManager {
constructor() {
this.headerContainer = document.getElementById('header-container');
this.currentHeaderType = null; // 'apikey' | 'main' | 'permission'
this.currentHeaderType = null; // 'welcome' | 'apikey' | 'main' | 'permission'
this.welcomeHeader = null;
this.apiKeyHeader = null;
this.mainHeader = null;
this.permissionHeader = null;
/**
* only one header window is allowed
* @param {'apikey'|'main'|'permission'} type
* @param {'welcome'|'apikey'|'main'|'permission'} type
*/
this.ensureHeader = (type) => {
console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
@ -23,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
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 { 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();
});
// Ollama 모델 다운로드 시작
this.installingModels = { ...this.installingModels, [modelName]: 0 };
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`);
// 진행률 이벤트 리스너 설정
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);
});
}
}