Compare commits

...

44 Commits

Author SHA1 Message Date
jhyang0
fa8d797be8 fix bridge 2025-07-13 23:56:13 +09:00
jhyang0
f18fae6c90 ux/ui fix 2025-07-13 23:29:33 +09:00
jhyang0
d62dad6992 remove business logic 2025-07-13 20:40:05 +09:00
jhyang0
e043b85bcd local llm bridge communication 2025-07-13 18:14:24 +09:00
samtiz
d936af46a3 rough refactor done 2025-07-13 15:12:05 +09:00
samtiz
586d44e57b Merge branch 'refactor/0712_ask' of https://github.com/pickle-com/glass into refactor/0712_ask 2025-07-13 11:08:43 +09:00
samtiz
5c2f9c1eb7 screenshot moved from windowManager 2025-07-13 11:08:41 +09:00
sanio
3c0654a0d4 change naming for featurebridge 2025-07-13 11:07:57 +09:00
samtiz
9ec8df0548 Merge branch 'refactor/0712_ask' of https://github.com/pickle-com/glass into refactor/0712_ask 2025-07-13 10:49:22 +09:00
samtiz
73a6e1345e shortcut moved 2025-07-13 10:49:19 +09:00
sanio
bb9061316c fix legacy code 2025-07-13 10:47:16 +09:00
sanio
09aaf1f62d retrieve conversation history for askserice 2025-07-13 10:42:42 +09:00
sanio
3d7738826c delete legacy ask code 2025-07-13 10:18:29 +09:00
samtiz
6d708d6dcd Merge branch 'refactor/0712_ask' of https://github.com/pickle-com/glass into refactor/0712_ask 2025-07-13 10:05:18 +09:00
samtiz
08c5aa4f6d modifying windowBridge 2025-07-13 10:05:09 +09:00
sanio
093f233f5a transfer roles from askview to askservice 2025-07-13 10:01:13 +09:00
sanio
c0edcfb0f9 Merge branch 'refactor/0712_ask' of https://github.com/pickle-com/glass into refactor/0712_ask 2025-07-13 09:22:12 +09:00
sanio
bf13a865ba refactor ask 2025-07-13 09:22:06 +09:00
samtiz
2063ab73ee shortcuts seperated 2025-07-13 05:33:13 +09:00
sanio
0992cd4668 fix preload conflict 2025-07-13 05:10:27 +09:00
sanio
18154e221c Merge branch 'refactor/0712_ask' of https://github.com/pickle-com/glass into refactor/0712_ask 2025-07-13 05:05:12 +09:00
sanio
a951d02a59 add featureBridge func for listenservice 2025-07-13 04:57:54 +09:00
jhyang0
c5b190b522 ui fix 2025-07-13 04:57:09 +09:00
samtiz
bf20d002ba featureBridge init 2025-07-13 04:25:35 +09:00
samtiz
2cf71f1034 Merge branch 'refactor/0712_ask' into refactor/0712_bridge 2025-07-13 03:15:05 +09:00
samtiz
f60e73c08c common service ipc moved to featureBridge 2025-07-13 03:14:08 +09:00
sanio
3bece73f78 Merge branch 'refactor/0712_ask' of https://github.com/pickle-com/glass into refactor/0712_ask 2025-07-13 02:48:08 +09:00
sanio
fc3c5e056a fix getCurrentModelInfo 2025-07-13 02:48:05 +09:00
samtiz
b2475c0940 Merge branch 'refactor/0712_ask' into refactor/0712_bridge 2025-07-13 02:27:30 +09:00
samtiz
817a8c5165 settingsService facade 2025-07-13 02:26:46 +09:00
sanio
b5b6f40995 change askservice to class 2025-07-13 02:03:31 +09:00
sanio
27f6f0e68e delete legacy code 2025-07-13 00:42:50 +09:00
sanio
c948d4ed08 centralized ask logic 2025-07-12 23:23:02 +09:00
sanio
8402e7d296 fix systemaudiodump path 2025-07-12 21:10:19 +09:00
sanio
5f007096d7 fix header content html path 2025-07-12 21:07:15 +09:00
sanio
6faa5d7ec7 Merge branch 'refactor/0712' of https://github.com/pickle-com/glass into refactor/0712 2025-07-12 20:34:38 +09:00
sanio
69053f4c0f fix askview 2025-07-12 20:34:36 +09:00
samtiz
d6ee8e07c5 resolve import err 2025-07-12 20:29:04 +09:00
sanio
8c5b10281a Merge branch 'refactor/0712' of https://github.com/pickle-com/glass into refactor/0712 2025-07-12 20:11:56 +09:00
sanio
43a9ce154f fixing ask logic 2025-07-12 20:11:53 +09:00
samtiz
9b409c58fe Merge branch 'refactor/0712' of https://github.com/pickle-com/glass into refactor/0712 2025-07-12 20:11:27 +09:00
samtiz
9eee95221e folder structure refactor 2025-07-12 20:11:20 +09:00
sanio
beedb909f9 Update aec submodule 2025-07-12 19:55:36 +09:00
samtiz
1bdc5fd1bd refactoring the bridge 2025-07-12 19:49:16 +09:00
112 changed files with 6096 additions and 5628 deletions

2
aec

@ -1 +1 @@
Subproject commit 3be088c6cff8021c74eca714150e68e2cc74bee0 Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f

View File

@ -14,8 +14,8 @@ const baseConfig = {
}; };
const entryPoints = [ const entryPoints = [
{ in: 'src/app/HeaderController.js', out: 'public/build/header' }, { in: 'src/ui/app/HeaderController.js', out: 'public/build/header' },
{ in: 'src/app/PickleGlassApp.js', out: 'public/build/content' }, { in: 'src/ui/app/PickleGlassApp.js', out: 'public/build/content' },
]; ];
async function build() { async function build() {

19
docs/refactor-plan.md Normal file
View File

@ -0,0 +1,19 @@
# Refactor Plan: Non-Window Logic Migration from windowManager.js
## Goal
`windowManager.js`를 순수 창 관리 모듈로 만들기 위해 비즈니스 로직을 해당 서비스와 `featureBridge.js`로 이전.
## Steps (based on initial plan)
1. **Shortcuts**: Completed. Logic moved to `shortcutsService.js` and IPC to `featureBridge.js`. Used `internalBridge` for coordination.
2. **Screenshot**: Next. Move `captureScreenshot` function and related IPC handlers from `windowManager.js` to `askService.js` (since it's primarily used there). Update `askService.js` to use its own screenshot method. Add IPC handlers to `featureBridge.js` if needed.
3. **System Permissions**: Create new `permissionService.js` in `src/features/common/services/`. Move all permission-related logic (check, request, open preferences, mark completed, etc.) and IPC handlers from `windowManager.js` to the new service and `featureBridge.js`.
4. **API Key / Model State**: Completely remove from `windowManager.js` (e.g., `setupApiKeyIPC` and helpers). Ensure all usages (e.g., in `askService.js`) directly require and use `modelStateService.js` instead.
## Notes
- Maintain original logic without changes.
- Break circular dependencies if found.
- Use `internalBridge` for inter-module communication where appropriate.
- After each step, verify no errors and test functionality.

View File

@ -33,7 +33,7 @@ extraResources:
to: out to: out
asarUnpack: asarUnpack:
- "src/assets/SystemAudioDump" - "src/ui/assets/SystemAudioDump"
- "**/node_modules/sharp/**/*" - "**/node_modules/sharp/**/*"
- "**/node_modules/@img/**/*" - "**/node_modules/@img/**/*"

File diff suppressed because it is too large Load Diff

119
src/bridge/featureBridge.js Normal file
View File

@ -0,0 +1,119 @@
// src/bridge/featureBridge.js
const { ipcMain, app } = require('electron');
const settingsService = require('../features/settings/settingsService');
const authService = require('../features/common/services/authService');
const whisperService = require('../features/common/services/whisperService');
const ollamaService = require('../features/common/services/ollamaService');
const modelStateService = require('../features/common/services/modelStateService');
const shortcutsService = require('../features/shortcuts/shortcutsService');
const presetRepository = require('../features/common/repositories/preset');
const askService = require('../features/ask/askService');
const listenService = require('../features/listen/listenService');
const permissionService = require('../features/common/services/permissionService');
module.exports = {
// Renderer로부터의 요청을 수신하고 서비스로 전달
initialize() {
// Settings Service
ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => await settingsService.setAutoUpdateSetting(isEnabled));
ipcMain.handle('settings:get-model-settings', async () => await settingsService.getModelSettings());
ipcMain.handle('settings:validate-and-save-key', async (e, { provider, key }) => await settingsService.validateAndSaveKey(provider, key));
ipcMain.handle('settings:clear-api-key', async (e, { provider }) => await settingsService.clearApiKey(provider));
ipcMain.handle('settings:set-selected-model', async (e, { type, modelId }) => await settingsService.setSelectedModel(type, modelId));
ipcMain.handle('settings:get-ollama-status', async () => await settingsService.getOllamaStatus());
ipcMain.handle('settings:ensure-ollama-ready', async () => await settingsService.ensureOllamaReady());
ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama());
// Shortcuts
ipcMain.handle('get-current-shortcuts', async () => await shortcutsService.loadKeybinds());
ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults());
ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
// Permissions
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section));
ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
// User/Auth
ipcMain.handle('get-current-user', () => authService.getCurrentUser());
ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow());
ipcMain.handle('firebase-logout', async () => await authService.signOut());
// App
ipcMain.handle('quit-application', () => app.quit());
// Whisper
ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(modelId));
ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
// General
ipcMain.handle('get-preset-templates', () => presetRepository.getPresetTemplates());
ipcMain.handle('get-web-url', () => process.env.pickleglass_WEB_URL || 'http://localhost:3000');
// Ollama
ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus());
ipcMain.handle('ollama:install', async () => await ollamaService.handleInstall());
ipcMain.handle('ollama:start-service', async () => await ollamaService.handleStartService());
ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady());
ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels());
ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions());
ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(modelName));
ipcMain.handle('ollama:is-model-installed', async (event, modelName) => await ollamaService.handleIsModelInstalled(modelName));
ipcMain.handle('ollama:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName));
ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp());
ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus());
ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(force));
// Ask
ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt));
ipcMain.handle('ask:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt));
ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton());
// Listen
ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType));
ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => {
const result = await listenService.sttService.sendSystemAudioContent(data, mimeType);
if(result.success) {
listenService.sendToRenderer('system-audio-data', { data });
}
return result;
});
ipcMain.handle('listen:startMacosSystemAudio', async () => await listenService.handleStartMacosAudio());
ipcMain.handle('listen:stopMacosSystemAudio', async () => await listenService.handleStopMacosAudio());
ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled));
ipcMain.handle('listen:changeSession', async (event, listenButtonText) => {
console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText);
try {
await listenService.handleListenRequest(listenButtonText);
return { success: true };
} catch (error) {
console.error('[FeatureBridge] listen:changeSession failed', error.message);
return { success: false, error: error.message };
}
});
// ModelStateService
ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys());
ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key));
ipcMain.handle('model:remove-api-key', async (e, provider) => await modelStateService.handleRemoveApiKey(provider));
ipcMain.handle('model:get-selected-models', () => modelStateService.getSelectedModels());
ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
console.log('[FeatureBridge] Initialized with all feature handlers.');
},
// Renderer로 상태를 전송
sendAskProgress(win, progress) {
win.webContents.send('feature:ask:progress', progress);
},
};

View File

@ -0,0 +1,11 @@
// src/bridge/internalBridge.js
const { EventEmitter } = require('events');
// FeatureCore와 WindowCore를 잇는 내부 이벤트 버스
const internalBridge = new EventEmitter();
module.exports = internalBridge;
// 예시 이벤트
// internalBridge.on('content-protection-changed', (enabled) => {
// // windowManager에서 처리
// });

View File

@ -0,0 +1,34 @@
// src/bridge/windowBridge.js
const { ipcMain, BrowserWindow } = require('electron');
const windowManager = require('../window/windowManager');
module.exports = {
initialize() {
ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection());
ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args));
ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus());
ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor());
ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds));
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));
// Newly moved handlers from windowManager
ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state));
ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state));
ipcMain.handle('get-header-position', () => windowManager.getHeaderPosition());
ipcMain.handle('move-header', (event, newX, newY) => windowManager.moveHeader(newX, newY));
ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight));
ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility());
ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender));
ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow());
},
notifyFocusChange(win, isFocused) {
win.webContents.send('window:focus-change', isFocused);
}
};

View File

@ -1,260 +0,0 @@
const http = require('http');
const fetch = require('node-fetch');
class OllamaProvider {
static async validateApiKey() {
try {
const response = await fetch('http://localhost:11434/api/tags');
if (response.ok) {
return { success: true };
} else {
return { success: false, error: 'Ollama service is not running. Please start Ollama first.' };
}
} catch (error) {
return { success: false, error: 'Cannot connect to Ollama. Please ensure Ollama is installed and running.' };
}
}
}
function convertMessagesToOllamaFormat(messages) {
return messages.map(msg => {
if (Array.isArray(msg.content)) {
let textContent = '';
const images = [];
for (const part of msg.content) {
if (part.type === 'text') {
textContent += part.text;
} else if (part.type === 'image_url') {
const base64 = part.image_url.url.replace(/^data:image\/[^;]+;base64,/, '');
images.push(base64);
}
}
return {
role: msg.role,
content: textContent,
...(images.length > 0 && { images })
};
} else {
return msg;
}
});
}
function createLLM({
model,
temperature = 0.7,
maxTokens = 2048,
baseUrl = 'http://localhost:11434',
...config
}) {
if (!model) {
throw new Error('Model parameter is required for Ollama LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
}
return {
generateContent: async (parts) => {
let systemPrompt = '';
const userContent = [];
for (const part of parts) {
if (typeof part === 'string') {
if (systemPrompt === '' && part.includes('You are')) {
systemPrompt = part;
} else {
userContent.push(part);
}
} else if (part.inlineData) {
userContent.push({
type: 'image',
image: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
});
}
}
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: userContent.join('\n') });
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
response: {
text: () => result.message.content
},
raw: result
};
} catch (error) {
console.error('Ollama LLM error:', error);
throw error;
}
},
chat: async (messages) => {
const ollamaMessages = convertMessagesToOllamaFormat(messages);
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
content: result.message.content,
raw: result
};
} catch (error) {
console.error('Ollama chat error:', error);
throw error;
}
}
};
}
function createStreamingLLM({
model,
temperature = 0.7,
maxTokens = 2048,
baseUrl = 'http://localhost:11434',
...config
}) {
if (!model) {
throw new Error('Model parameter is required for Ollama streaming LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
}
return {
streamChat: async (messages) => {
console.log('[Ollama Provider] Starting streaming request');
const ollamaMessages = convertMessagesToOllamaFormat(messages);
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: true,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
console.log('[Ollama Provider] Got streaming response');
const stream = new ReadableStream({
async start(controller) {
let buffer = '';
try {
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.message?.content) {
const sseData = JSON.stringify({
choices: [{
delta: {
content: data.message.content
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
}
if (data.done) {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
}
} catch (e) {
console.error('[Ollama Provider] Failed to parse chunk:', e);
}
}
});
response.body.on('end', () => {
controller.close();
console.log('[Ollama Provider] Streaming completed');
});
response.body.on('error', (error) => {
console.error('[Ollama Provider] Streaming error:', error);
controller.error(error);
});
} catch (error) {
console.error('[Ollama Provider] Streaming setup error:', error);
controller.error(error);
}
}
});
return {
ok: true,
body: stream
};
} catch (error) {
console.error('[Ollama Provider] Request error:', error);
throw error;
}
}
};
}
module.exports = {
OllamaProvider,
createLLM,
createStreamingLLM,
convertMessagesToOllamaFormat
};

View File

@ -1,133 +0,0 @@
export class LocalProgressTracker {
constructor(serviceName) {
this.serviceName = serviceName;
this.activeOperations = new Map(); // operationId -> { controller, onProgress }
this.ipcRenderer = window.require?.('electron')?.ipcRenderer;
if (!this.ipcRenderer) {
throw new Error(`${serviceName} requires Electron environment`);
}
this.globalProgressHandler = (event, data) => {
const operation = this.activeOperations.get(data.model || data.modelId);
if (operation && !operation.controller.signal.aborted) {
operation.onProgress(data.progress);
}
};
const progressEvents = {
'ollama': 'ollama:pull-progress',
'whisper': 'whisper:download-progress'
};
const eventName = progressEvents[serviceName.toLowerCase()] || `${serviceName}:progress`;
this.progressEvent = eventName;
this.ipcRenderer.on(eventName, this.globalProgressHandler);
}
async trackOperation(operationId, operationType, onProgress) {
if (this.activeOperations.has(operationId)) {
throw new Error(`${operationType} ${operationId} is already in progress`);
}
const controller = new AbortController();
const operation = { controller, onProgress };
this.activeOperations.set(operationId, operation);
try {
const ipcChannels = {
'ollama': { install: 'ollama:pull-model' },
'whisper': { download: 'whisper:download-model' }
};
const channel = ipcChannels[this.serviceName.toLowerCase()]?.[operationType] ||
`${this.serviceName}:${operationType}`;
const result = await this.ipcRenderer.invoke(channel, operationId);
if (!result.success) {
throw new Error(result.error || `${operationType} failed`);
}
return true;
} catch (error) {
if (!controller.signal.aborted) {
throw error;
}
return false;
} finally {
this.activeOperations.delete(operationId);
}
}
async installModel(modelName, onProgress) {
return this.trackOperation(modelName, 'install', onProgress);
}
async downloadModel(modelId, onProgress) {
return this.trackOperation(modelId, 'download', onProgress);
}
cancelOperation(operationId) {
const operation = this.activeOperations.get(operationId);
if (operation) {
operation.controller.abort();
this.activeOperations.delete(operationId);
}
}
cancelAllOperations() {
for (const [operationId, operation] of this.activeOperations) {
operation.controller.abort();
}
this.activeOperations.clear();
}
isOperationActive(operationId) {
return this.activeOperations.has(operationId);
}
getActiveOperations() {
return Array.from(this.activeOperations.keys());
}
destroy() {
this.cancelAllOperations();
if (this.ipcRenderer) {
this.ipcRenderer.removeListener(this.progressEvent, this.globalProgressHandler);
}
}
}
let trackers = new Map();
export function getLocalProgressTracker(serviceName) {
if (!trackers.has(serviceName)) {
trackers.set(serviceName, new LocalProgressTracker(serviceName));
}
return trackers.get(serviceName);
}
export function destroyLocalProgressTracker(serviceName) {
const tracker = trackers.get(serviceName);
if (tracker) {
tracker.destroy();
trackers.delete(serviceName);
}
}
export function destroyAllProgressTrackers() {
for (const [name, tracker] of trackers) {
tracker.destroy();
}
trackers.clear();
}
// Legacy compatibility exports
export function getOllamaProgressTracker() {
return getLocalProgressTracker('ollama');
}
export function destroyOllamaProgressTracker() {
destroyLocalProgressTracker('ollama');
}

File diff suppressed because it is too large Load Diff

View File

@ -1,150 +1,436 @@
const { ipcMain, BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { createStreamingLLM } = require('../../common/ai/factory'); const { createStreamingLLM } = require('../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../electron/windowManager'); // Lazy require helper to avoid circular dependency issues
const authService = require('../../common/services/authService'); const getWindowManager = () => require('../../window/windowManager');
const sessionRepository = require('../../common/repositories/session');
const getWindowPool = () => {
try {
return getWindowManager().windowPool;
} catch {
return null;
}
};
const updateLayout = () => getWindowManager().updateLayout();
const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible();
const sessionRepository = require('../common/repositories/session');
const askRepository = require('./repositories'); const askRepository = require('./repositories');
const { getSystemPrompt } = require('../../common/prompts/promptBuilder'); const { getSystemPrompt } = require('../common/prompts/promptBuilder');
const path = require('node:path');
const fs = require('node:fs');
const os = require('os');
const util = require('util');
const execFile = util.promisify(require('child_process').execFile);
const { desktopCapturer } = require('electron');
const modelStateService = require('../common/services/modelStateService');
function formatConversationForPrompt(conversationTexts) { // Try to load sharp, but don't fail if it's not available
if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.'; let sharp;
return conversationTexts.slice(-30).join('\n'); try {
sharp = require('sharp');
console.log('[AskService] Sharp module loaded successfully');
} catch (error) {
console.warn('[AskService] Sharp module not available:', error.message);
console.warn('[AskService] Screenshot functionality will work with reduced image processing capabilities');
sharp = null;
} }
let lastScreenshot = null;
// Access conversation history via the global listenService instance created in index.js async function captureScreenshot(options = {}) {
function getConversationHistory() { if (process.platform === 'darwin') {
const listenService = global.listenService; try {
return listenService ? listenService.getConversationHistory() : []; const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`);
}
async function sendMessage(userPrompt) { await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]);
if (!userPrompt || userPrompt.trim().length === 0) {
console.warn('[AskService] Cannot process empty message'); const imageBuffer = await fs.promises.readFile(tempPath);
return { success: false, error: 'Empty message' }; await fs.promises.unlink(tempPath);
if (sharp) {
try {
// Try using sharp for optimal image processing
const resizedBuffer = await sharp(imageBuffer)
.resize({ height: 384 })
.jpeg({ quality: 80 })
.toBuffer();
const base64 = resizedBuffer.toString('base64');
const metadata = await sharp(resizedBuffer).metadata();
lastScreenshot = {
base64,
width: metadata.width,
height: metadata.height,
timestamp: Date.now(),
};
return { success: true, base64, width: metadata.width, height: metadata.height };
} catch (sharpError) {
console.warn('Sharp module failed, falling back to basic image processing:', sharpError.message);
}
}
// Fallback: Return the original image without resizing
console.log('[AskService] Using fallback image processing (no resize/compression)');
const base64 = imageBuffer.toString('base64');
lastScreenshot = {
base64,
width: null, // We don't have metadata without sharp
height: null,
timestamp: Date.now(),
};
return { success: true, base64, width: null, height: null };
} catch (error) {
console.error('Failed to capture screenshot:', error);
return { success: false, error: error.message };
}
} }
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
askWindow.webContents.send('hide-text-input');
}
let sessionId;
try { try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); const sources = await desktopCapturer.getSources({
types: ['screen'],
// --- Save user's message immediately --- thumbnailSize: {
// This ensures the user message is always timestamped before the assistant's response. width: 1920,
sessionId = await sessionRepository.getOrCreateActive('ask'); height: 1080,
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
// --- End of user message saving ---
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.');
}
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
const screenshotResult = await captureScreenshot({ quality: 'medium' });
const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
const conversationHistoryRaw = getConversationHistory();
const conversationHistory = formatConversationForPrompt(conversationHistoryRaw);
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
const messages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
],
}, },
];
if (screenshotBase64) {
messages[1].content.push({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
});
}
const streamingLLM = createStreamingLLM(modelInfo.provider, {
apiKey: modelInfo.apiKey,
model: modelInfo.model,
temperature: 0.7,
maxTokens: 2048,
usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
}); });
const response = await streamingLLM.streamChat(messages); if (sources.length === 0) {
throw new Error('No screen sources available');
}
const source = sources[0];
const buffer = source.thumbnail.toJPEG(70);
const base64 = buffer.toString('base64');
const size = source.thumbnail.getSize();
// --- Stream Processing --- return {
const reader = response.body.getReader(); success: true,
base64,
width: size.width,
height: size.height,
};
} catch (error) {
console.error('Failed to capture screenshot using desktopCapturer:', error);
return {
success: false,
error: error.message,
};
}
}
/**
* @class
* @description
*/
class AskService {
constructor() {
this.abortController = null;
this.state = {
isVisible: false,
isLoading: false,
isStreaming: false,
currentQuestion: '',
currentResponse: '',
showTextInput: true,
};
console.log('[AskService] Service instance created.');
}
_broadcastState() {
const askWindow = getWindowPool()?.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
askWindow.webContents.send('ask:stateUpdate', this.state);
}
}
async toggleAskButton() {
const askWindow = getWindowPool()?.get('ask');
// 답변이 있거나 스트리밍 중일 때
const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
if (askWindow && askWindow.isVisible() && hasContent) {
// 창을 닫는 대신, 텍스트 입력창만 토글합니다.
this.state.showTextInput = !this.state.showTextInput;
this._broadcastState(); // 변경된 상태 전파
} else {
// 기존의 창 보이기/숨기기 로직
if (askWindow && askWindow.isVisible()) {
askWindow.webContents.send('window-hide-animation');
this.state.isVisible = false;
} else {
console.log('[AskService] Showing hidden Ask window');
this.state.isVisible = true;
askWindow?.show();
updateLayout();
askWindow?.webContents.send('window-show-animation');
}
// 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다.
if (this.state.isVisible) {
this.state.showTextInput = true;
this._broadcastState();
}
}
}
/**
*
* @param {string[]} conversationTexts
* @returns {string}
* @private
*/
_formatConversationForPrompt(conversationTexts) {
if (!conversationTexts || conversationTexts.length === 0) {
return 'No conversation history available.';
}
return conversationTexts.slice(-30).join('\n');
}
/**
*
* @param {string} userPrompt
* @returns {Promise<{success: boolean, response?: string, error?: string}>}
*/
async sendMessage(userPrompt, conversationHistoryRaw=[]) {
ensureAskWindowVisible();
if (this.abortController) {
this.abortController.abort('New request received.');
}
this.abortController = new AbortController();
const { signal } = this.abortController;
if (!userPrompt || userPrompt.trim().length === 0) {
console.warn('[AskService] Cannot process empty message');
return { success: false, error: 'Empty message' };
}
let sessionId;
try {
console.log(`[AskService] Processing message: ${userPrompt.substring(0, 50)}...`);
this.state = {
...this.state,
isLoading: true,
isStreaming: false,
currentQuestion: userPrompt,
currentResponse: '',
showTextInput: false,
};
this._broadcastState();
sessionId = await sessionRepository.getOrCreateActive('ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
const modelInfo = modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.');
}
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
const screenshotResult = await captureScreenshot({ quality: 'medium' });
const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
const conversationHistory = this._formatConversationForPrompt(conversationHistoryRaw);
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
// 첫 번째 시도: 스크린샷 포함 (가능한 경우)
const messages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
],
},
];
if (screenshotBase64) {
messages[1].content.push({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
});
}
const streamingLLM = createStreamingLLM(modelInfo.provider, {
apiKey: modelInfo.apiKey,
model: modelInfo.model,
temperature: 0.7,
maxTokens: 2048,
usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
});
try {
const response = await streamingLLM.streamChat(messages);
const askWin = getWindowPool()?.get('ask');
if (!askWin || askWin.isDestroyed()) {
console.error("[AskService] Ask window is not available to send stream to.");
response.body.getReader().cancel();
return { success: false, error: 'Ask window is not available.' };
}
const reader = response.body.getReader();
signal.addEventListener('abort', () => {
console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`);
reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ });
});
await this._processStream(reader, askWin, sessionId, signal);
return { success: true };
} catch (multimodalError) {
// 멀티모달 요청이 실패했고 스크린샷이 포함되어 있다면 텍스트만으로 재시도
if (screenshotBase64 && this._isMultimodalError(multimodalError)) {
console.log(`[AskService] Multimodal request failed, retrying with text-only: ${multimodalError.message}`);
// 텍스트만으로 메시지 재구성
const textOnlyMessages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: `User Request: ${userPrompt.trim()}`
}
];
const fallbackResponse = await streamingLLM.streamChat(textOnlyMessages);
const askWin = getWindowPool()?.get('ask');
if (!askWin || askWin.isDestroyed()) {
console.error("[AskService] Ask window is not available for fallback response.");
fallbackResponse.body.getReader().cancel();
return { success: false, error: 'Ask window is not available.' };
}
const fallbackReader = fallbackResponse.body.getReader();
signal.addEventListener('abort', () => {
console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`);
fallbackReader.cancel(signal.reason).catch(() => {});
});
await this._processStream(fallbackReader, askWin, sessionId, signal);
return { success: true };
} else {
// 다른 종류의 에러이거나 스크린샷이 없었다면 그대로 throw
throw multimodalError;
}
}
} catch (error) {
console.error('[AskService] Error during message processing:', error);
this.state = {
...this.state,
isLoading: false,
isStreaming: false,
showTextInput: true,
};
this._broadcastState();
const askWin = getWindowPool()?.get('ask');
if (askWin && !askWin.isDestroyed()) {
const streamError = error.message || 'Unknown error occurred';
askWin.webContents.send('ask-response-stream-error', { error: streamError });
}
return { success: false, error: error.message };
}
}
/**
*
* @param {ReadableStreamDefaultReader} reader
* @param {BrowserWindow} askWin
* @param {number} sessionId
* @param {AbortSignal} signal
* @returns {Promise<void>}
* @private
*/
async _processStream(reader, askWin, sessionId, signal) {
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let fullResponse = ''; let fullResponse = '';
const askWin = windowPool.get('ask'); try {
if (!askWin || askWin.isDestroyed()) { this.state.isLoading = false;
console.error("[AskService] Ask window is not available to send stream to."); this.state.isStreaming = true;
reader.cancel(); this._broadcastState();
return; while (true) {
} const { done, value } = await reader.read();
if (done) break;
while (true) { const chunk = decoder.decode(value);
const { done, value } = await reader.read(); const lines = chunk.split('\n').filter(line => line.trim() !== '');
if (done) break;
const chunk = decoder.decode(value); for (const line of lines) {
const lines = chunk.split('\n').filter(line => line.trim() !== ''); if (line.startsWith('data: ')) {
const data = line.substring(6);
for (const line of lines) { if (data === '[DONE]') {
if (line.startsWith('data: ')) { return;
const data = line.substring(6); }
if (data === '[DONE]') {
askWin.webContents.send('ask-response-stream-end');
// Save assistant's message to DB
try { try {
// sessionId is already available from when we saved the user prompt const json = JSON.parse(data);
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); const token = json.choices[0]?.delta?.content || '';
console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`); if (token) {
} catch(dbError) { fullResponse += token;
console.error("[AskService] DB: Failed to save assistant response:", dbError); this.state.currentResponse = fullResponse;
this._broadcastState();
}
} catch (error) {
console.error('[AskService] Failed to parse stream data:', { line: data, error: error.message });
} }
return { success: true, response: fullResponse };
}
try {
const json = JSON.parse(data);
const token = json.choices[0]?.delta?.content || '';
if (token) {
fullResponse += token;
askWin.webContents.send('ask-response-chunk', { token });
}
} catch (error) {
// Ignore parsing errors for now
} }
} }
} }
} catch (streamError) {
if (signal.aborted) {
console.log(`[AskService] Stream reading was intentionally cancelled. Reason: ${signal.reason}`);
} else {
console.error('[AskService] Error while processing stream:', streamError);
if (askWin && !askWin.isDestroyed()) {
askWin.webContents.send('ask-response-stream-error', { error: streamError.message });
}
}
} finally {
this.state.isStreaming = false;
this.state.currentResponse = fullResponse;
this._broadcastState();
if (fullResponse) {
try {
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
console.log(`[AskService] DB: Saved partial or full assistant response to session ${sessionId} after stream ended.`);
} catch(dbError) {
console.error("[AskService] DB: Failed to save assistant response after stream ended:", dbError);
}
}
} }
} catch (error) { }
console.error('[AskService] Error processing message:', error);
return { success: false, error: error.message }; /**
* 멀티모달 관련 에러인지 판단
* @private
*/
_isMultimodalError(error) {
const errorMessage = error.message?.toLowerCase() || '';
return (
errorMessage.includes('vision') ||
errorMessage.includes('image') ||
errorMessage.includes('multimodal') ||
errorMessage.includes('unsupported') ||
errorMessage.includes('image_url') ||
errorMessage.includes('400') || // Bad Request often for unsupported features
errorMessage.includes('invalid') ||
errorMessage.includes('not supported')
);
} }
} }
function initialize() { const askService = new AskService();
ipcMain.handle('ask:sendMessage', async (event, userPrompt) => {
return sendMessage(userPrompt);
});
console.log('[AskService] Initialized and ready.');
}
module.exports = { module.exports = askService;
initialize,
};

View File

@ -1,6 +1,6 @@
const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore'); const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); const { getFirestoreInstance } = require('../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter');
const aiMessageConverter = createEncryptedConverter(['content']); const aiMessageConverter = createEncryptedConverter(['content']);

View File

@ -1,6 +1,6 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService'); const authService = require('../../common/services/authService');
function getBaseRepository() { function getBaseRepository() {
const user = authService.getCurrentUser(); const user = authService.getCurrentUser();

View File

@ -1,4 +1,4 @@
const sqliteClient = require('../../../common/services/sqliteClient'); const sqliteClient = require('../../common/services/sqliteClient');
function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) { function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) {
// uid is ignored in the SQLite implementation // uid is ignored in the SQLite implementation

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

@ -0,0 +1,342 @@
const http = require('http');
const fetch = require('node-fetch');
// Request Queue System for Ollama API (only for non-streaming requests)
class RequestQueue {
constructor() {
this.queue = [];
this.processing = false;
this.streamingActive = false;
}
async addStreamingRequest(requestFn) {
// Streaming requests have priority - wait for current processing to finish
while (this.processing) {
await new Promise(resolve => setTimeout(resolve, 50));
}
this.streamingActive = true;
console.log('[Ollama Queue] Starting streaming request (priority)');
try {
const result = await requestFn();
return result;
} finally {
this.streamingActive = false;
console.log('[Ollama Queue] Streaming request completed');
}
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) {
return;
}
// Wait if streaming is active
if (this.streamingActive) {
setTimeout(() => this.process(), 100);
return;
}
this.processing = true;
while (this.queue.length > 0) {
// Check if streaming started while processing queue
if (this.streamingActive) {
this.processing = false;
setTimeout(() => this.process(), 100);
return;
}
const { requestFn, resolve, reject } = this.queue.shift();
try {
console.log(`[Ollama Queue] Processing queued request (${this.queue.length} remaining)`);
const result = await requestFn();
resolve(result);
} catch (error) {
console.error('[Ollama Queue] Request failed:', error);
reject(error);
}
}
this.processing = false;
}
}
// Global request queue instance
const requestQueue = new RequestQueue();
class OllamaProvider {
static async validateApiKey() {
try {
const response = await fetch('http://localhost:11434/api/tags');
if (response.ok) {
return { success: true };
} else {
return { success: false, error: 'Ollama service is not running. Please start Ollama first.' };
}
} catch (error) {
return { success: false, error: 'Cannot connect to Ollama. Please ensure Ollama is installed and running.' };
}
}
}
function convertMessagesToOllamaFormat(messages) {
return messages.map(msg => {
if (Array.isArray(msg.content)) {
let textContent = '';
const images = [];
for (const part of msg.content) {
if (part.type === 'text') {
textContent += part.text;
} else if (part.type === 'image_url') {
const base64 = part.image_url.url.replace(/^data:image\/[^;]+;base64,/, '');
images.push(base64);
}
}
return {
role: msg.role,
content: textContent,
...(images.length > 0 && { images })
};
} else {
return msg;
}
});
}
function createLLM({
model,
temperature = 0.7,
maxTokens = 2048,
baseUrl = 'http://localhost:11434',
...config
}) {
if (!model) {
throw new Error('Model parameter is required for Ollama LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
}
return {
generateContent: async (parts) => {
let systemPrompt = '';
const userContent = [];
for (const part of parts) {
if (typeof part === 'string') {
if (systemPrompt === '' && part.includes('You are')) {
systemPrompt = part;
} else {
userContent.push(part);
}
} else if (part.inlineData) {
userContent.push({
type: 'image',
image: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
});
}
}
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: userContent.join('\n') });
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
response: {
text: () => result.message.content
},
raw: result
};
} catch (error) {
console.error('Ollama LLM error:', error);
throw error;
}
});
},
chat: async (messages) => {
const ollamaMessages = convertMessagesToOllamaFormat(messages);
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
content: result.message.content,
raw: result
};
} catch (error) {
console.error('Ollama chat error:', error);
throw error;
}
});
}
};
}
function createStreamingLLM({
model,
temperature = 0.7,
maxTokens = 2048,
baseUrl = 'http://localhost:11434',
...config
}) {
if (!model) {
throw new Error('Model parameter is required for Ollama streaming LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
}
return {
streamChat: async (messages) => {
console.log('[Ollama Provider] Starting streaming request');
const ollamaMessages = convertMessagesToOllamaFormat(messages);
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
// Streaming requests have priority over queued requests
return await requestQueue.addStreamingRequest(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: true,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
console.log('[Ollama Provider] Got streaming response');
const stream = new ReadableStream({
async start(controller) {
let buffer = '';
try {
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.message?.content) {
const sseData = JSON.stringify({
choices: [{
delta: {
content: data.message.content
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
}
if (data.done) {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
}
} catch (e) {
console.error('[Ollama Provider] Failed to parse chunk:', e);
}
}
});
response.body.on('end', () => {
controller.close();
console.log('[Ollama Provider] Streaming completed');
});
response.body.on('error', (error) => {
console.error('[Ollama Provider] Streaming error:', error);
controller.error(error);
});
} catch (error) {
console.error('[Ollama Provider] Streaming setup error:', error);
controller.error(error);
}
}
});
return {
ok: true,
body: stream
};
} catch (error) {
console.error('[Ollama Provider] Request error:', error);
throw error;
}
});
}
};
}
module.exports = {
OllamaProvider,
createLLM,
createStreamingLLM,
convertMessagesToOllamaFormat
};

View File

@ -82,7 +82,7 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
silence_duration_ms: 100, silence_duration_ms: 100,
}, },
input_audio_noise_reduction: { input_audio_noise_reduction: {
type: 'far_field' type: 'near_field'
} }
} }
}; };

View File

@ -184,9 +184,10 @@ 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();
}
} }
} }

View File

@ -110,6 +110,13 @@ const LATEST_SCHEMA = {
{ name: 'selected_stt_model', type: 'TEXT' }, { name: 'selected_stt_model', type: 'TEXT' },
{ name: 'updated_at', type: 'INTEGER' } { name: 'updated_at', type: 'INTEGER' }
] ]
},
shortcuts: {
columns: [
{ name: 'action', type: 'TEXT PRIMARY KEY' },
{ name: 'accelerator', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' }
]
} }
}; };

View File

@ -1,6 +1,6 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService'); const authService = require('../../services/authService');
function getBaseRepository() { function getBaseRepository() {
const user = authService.getCurrentUser(); const user = authService.getCurrentUser();

View File

@ -1,5 +1,5 @@
const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth'); const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
const { BrowserWindow } = require('electron'); const { BrowserWindow, shell } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient'); const { getFirebaseAuth } = require('./firebaseClient');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const encryptionService = require('./encryptionService'); const encryptionService = require('./encryptionService');
@ -131,6 +131,19 @@ class AuthService {
return this.initializationPromise; return this.initializationPromise;
} }
async startFirebaseAuthFlow() {
try {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
const authUrl = `${webUrl}/login?mode=electron`;
console.log(`[AuthService] Opening Firebase auth URL in browser: ${authUrl}`);
await shell.openExternal(authUrl);
return { success: true };
} catch (error) {
console.error('[AuthService] Failed to open Firebase auth URL:', error);
return { success: false, error: error.message };
}
}
async signInWithCustomToken(token) { async signInWithCustomToken(token) {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
try { try {

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,9 +205,15 @@ 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);
onProgress(progress, downloadedSize, totalSize);
// 이벤트 기반 진행률 보고는 각 서비스에서 직접 처리
// 기존 콜백 지원 (호환성 유지)
if (onProgress) {
onProgress(progress, downloadedSize, totalSize);
}
} }
}); });
@ -200,7 +221,7 @@ class LocalAIServiceBase extends EventEmitter {
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

@ -5,9 +5,9 @@ const encryptionService = require('../services/encryptionService');
const sqliteSessionRepo = require('../repositories/session/sqlite.repository'); const sqliteSessionRepo = require('../repositories/session/sqlite.repository');
const sqlitePresetRepo = require('../repositories/preset/sqlite.repository'); const sqlitePresetRepo = require('../repositories/preset/sqlite.repository');
const sqliteUserRepo = require('../repositories/user/sqlite.repository'); const sqliteUserRepo = require('../repositories/user/sqlite.repository');
const sqliteSttRepo = require('../../features/listen/stt/repositories/sqlite.repository'); const sqliteSttRepo = require('../../listen/stt/repositories/sqlite.repository');
const sqliteSummaryRepo = require('../../features/listen/summary/repositories/sqlite.repository'); const sqliteSummaryRepo = require('../../listen/summary/repositories/sqlite.repository');
const sqliteAiMessageRepo = require('../../features/ask/repositories/sqlite.repository'); const sqliteAiMessageRepo = require('../../ask/repositories/sqlite.repository');
const MAX_BATCH_OPERATIONS = 500; const MAX_BATCH_OPERATIONS = 500;

View File

@ -1,13 +1,18 @@
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');
const userModelSelectionsRepository = require('../repositories/userModelSelections'); const userModelSelectionsRepository = require('../repositories/userModelSelections');
class ModelStateService { // Import authService directly (singleton)
constructor(authService) { const authService = require('./authService');
class ModelStateService extends EventEmitter {
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 = {};
@ -18,10 +23,22 @@ 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();
this.setupIpcHandlers();
console.log('[ModelStateService] Initialization complete'); console.log('[ModelStateService] Initialization complete');
} }
@ -34,15 +51,17 @@ class ModelStateService {
console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`); console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
} }
_autoSelectAvailableModels() { _autoSelectAvailableModels(forceReselectionForTypes = []) {
console.log('[ModelStateService] Running auto-selection for models...'); console.log(`[ModelStateService] Running auto-selection for models. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
const types = ['llm', 'stt']; const types = ['llm', 'stt'];
types.forEach(type => { types.forEach(type => {
const currentModelId = this.state.selectedModels[type]; const currentModelId = this.state.selectedModels[type];
let isCurrentModelValid = false; let isCurrentModelValid = false;
if (currentModelId) { const forceReselection = forceReselectionForTypes.includes(type);
if (currentModelId && !forceReselection) {
const provider = this.getProviderForModel(type, currentModelId); const provider = this.getProviderForModel(type, currentModelId);
const apiKey = this.getApiKey(provider); const apiKey = this.getApiKey(provider);
// For Ollama, 'local' is a valid API key // For Ollama, 'local' is a valid API key
@ -52,7 +71,7 @@ class ModelStateService {
} }
if (!isCurrentModelValid) { if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`); console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or re-selection forced. Finding an alternative...`);
const availableModels = this.getAvailableModels(type); const availableModels = this.getAvailableModels(type);
if (availableModels.length > 0) { if (availableModels.length > 0) {
// Prefer API providers over local providers for auto-selection // Prefer API providers over local providers for auto-selection
@ -167,6 +186,9 @@ class ModelStateService {
console.log(`[ModelStateService] State loaded from database for user: ${userId}`); 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
@ -326,13 +348,26 @@ class ModelStateService {
this._logCurrentSelection(); this._logCurrentSelection();
} }
setApiKey(provider, key) { async setApiKey(provider, key) {
if (provider in this.state.apiKeys) { console.log(`[ModelStateService] setApiKey: ${provider}`);
this.state.apiKeys[provider] = key; if (!provider) {
this._saveState(); throw new Error('Provider is required');
return true;
} }
return false;
let finalKey = key;
// Handle encryption for non-firebase providers
if (provider !== 'firebase' && key && key !== 'local') {
finalKey = await encryptionService.encrypt(key);
}
this.state.apiKeys[provider] = finalKey;
await this._saveState();
this._autoSelectAvailableModels([]);
this._broadcastToAllWindows('model-state:updated', this.state);
this._broadcastToAllWindows('settings-updated');
} }
getApiKey(provider) { getApiKey(provider) {
@ -344,18 +379,14 @@ class ModelStateService {
return displayKeys; return displayKeys;
} }
removeApiKey(provider) { async removeApiKey(provider) {
if (provider in this.state.apiKeys) { if (this.state.apiKeys[provider]) {
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;
@ -392,6 +423,8 @@ class ModelStateService {
areProvidersConfigured() { areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true; if (this.isLoggedInWithFirebase()) return true;
console.log('[DEBUG] Checking configured providers with apiKeys state:', JSON.stringify(this.state.apiKeys, (key, value) => (value ? '***' : null), 2));
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인 // LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => { const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'ollama') { if (provider === 'ollama') {
@ -439,11 +472,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]);
}
}
// 다른 provider들은 기존 로직 사용
else if (PROVIDERS[providerId]?.[modelList]) {
available.push(...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()];
} }
@ -452,20 +510,31 @@ 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];
this.state.selectedModels[type] = modelId; if (!isAvailable) {
this._saveState(); console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`);
return false;
// Auto warm-up for Ollama LLM models when changed
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
this._autoWarmUpOllamaModel(modelId, previousModel);
}
return true;
} }
return false;
const previousModelId = this.state.selectedModels[type];
this.state.selectedModels[type] = modelId;
this._saveState();
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`);
// Auto warm-up for Ollama models
if (type === 'llm' && modelId && modelId !== previousModelId) {
const provider = this.getProviderForModel('llm', modelId);
if (provider === 'ollama') {
this._autoWarmUpOllamaModel(modelId, previousModelId);
}
}
this._broadcastToAllWindows('model-state:updated', this.state);
this._broadcastToAllWindows('settings-updated');
return true;
} }
/** /**
@ -476,7 +545,7 @@ class ModelStateService {
*/ */
async _autoWarmUpOllamaModel(newModelId, previousModelId) { 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');
@ -492,12 +561,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
@ -506,6 +575,41 @@ class ModelStateService {
} }
} }
getProviderConfig() {
const serializableProviders = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
serializableProviders[key] = rest;
}
return serializableProviders;
}
async handleValidateKey(provider, key) {
const result = await this.validateApiKey(provider, key);
if (result.success) {
// Use 'local' as placeholder for local services
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
await this.setApiKey(provider, finalKey);
}
return result;
}
async handleRemoveApiKey(provider) {
console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`);
const success = await this.removeApiKey(provider);
if (success) {
const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) {
this._broadcastToAllWindows('force-show-apikey-header');
}
}
return success;
}
async handleSetSelectedModel(type, modelId) {
return this.setSelectedModel(type, modelId);
}
/** /**
* *
* @param {('llm' | 'stt')} type * @param {('llm' | 'stt')} type
@ -527,55 +631,8 @@ class ModelStateService {
return { provider, model, apiKey }; return { provider, model, apiKey };
} }
setupIpcHandlers() {
ipcMain.handle('model:validate-key', async (e, { provider, key }) => {
const result = await this.validateApiKey(provider, key);
if (result.success) {
// Use 'local' as placeholder for local services
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
this.setApiKey(provider, finalKey);
// After setting the key, auto-select models
this._autoSelectAvailableModels();
this._saveState(); // Ensure state is saved after model selection
}
return result;
});
ipcMain.handle('model:get-all-keys', () => this.getAllApiKeys());
ipcMain.handle('model:set-api-key', async (e, { provider, key }) => {
const success = this.setApiKey(provider, key);
if (success) {
this._autoSelectAvailableModels();
await this._saveState();
}
return success;
});
ipcMain.handle('model:remove-api-key', async (e, { provider }) => {
const success = this.removeApiKey(provider);
if (success) {
const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) {
webContents.getAllWebContents().forEach(wc => {
wc.send('force-show-apikey-header');
});
}
}
return success;
});
ipcMain.handle('model:get-selected-models', () => this.getSelectedModels());
ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => this.setSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', (e, { type }) => this.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', () => this.areProvidersConfigured());
ipcMain.handle('model:get-current-model-info', (e, { type }) => this.getCurrentModelInfo(type));
ipcMain.handle('model:get-provider-config', () => {
const serializableProviders = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
serializableProviders[key] = rest;
}
return serializableProviders;
});
}
} }
module.exports = ModelStateService; // Export singleton instance
const modelStateService = new ModelStateService();
module.exports = modelStateService;

View File

@ -3,10 +3,11 @@ 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');
const ollamaModelRepository = require('../repositories/ollamaModel');
class OllamaService extends LocalAIServiceBase { class OllamaService extends LocalAIServiceBase {
constructor() { constructor() {
@ -26,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
@ -39,6 +40,39 @@ 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() {
try {
const installed = await this.isInstalled();
if (!installed) {
return { success: true, installed: false, running: false, models: [] };
}
const running = await this.isServiceRunning();
if (!running) {
return { success: true, installed: true, running: false, models: [] };
}
const models = await this.getInstalledModels();
return { success: true, installed: true, running: true, models };
} catch (error) {
console.error('[OllamaService] Error getting status:', error);
return { success: false, error: error.message, installed: false, running: false, models: [] };
}
}
getOllamaCliPath() { getOllamaCliPath() {
if (this.getPlatform() === 'darwin') { if (this.getPlatform() === 'darwin') {
return '/Applications/Ollama.app/Contents/Resources/ollama'; return '/Applications/Ollama.app/Contents/Resources/ollama';
@ -66,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;
controller.abort(); if (timeout > 0) {
this.activeRequests.delete(requestId); timeoutId = setTimeout(() => {
this._recordFailure(); controller.abort();
}, timeout); this.activeRequests.delete(requestId);
this._recordFailure();
this.requestTimeouts.set(requestId, timeoutId); }, timeout);
this.requestTimeouts.set(requestId, timeoutId);
}
const requestPromise = this._executeRequest(url, { const requestPromise = this._executeRequest(url, {
...options, ...options,
@ -94,8 +131,10 @@ class OllamaService extends LocalAIServiceBase {
} }
throw error; throw error;
} finally { } finally {
clearTimeout(timeoutId); if (timeoutId !== null) {
this.requestTimeouts.delete(requestId); clearTimeout(timeoutId);
this.requestTimeouts.delete(requestId);
}
this.activeRequests.delete(operationType === 'health' ? 'health' : requestId); this.activeRequests.delete(operationType === 'health' ? 'health' : requestId);
} }
} }
@ -356,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'
@ -367,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;
@ -385,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);
@ -618,8 +657,48 @@ class OllamaService extends LocalAIServiceBase {
return true; return true;
} catch (error) { } catch (error) {
console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message); // Check if it's a 404 error (model not found/installed)
return false; if (error.message.includes('HTTP 404') || error.message.includes('Not Found')) {
console.log(`[OllamaService] Model ${modelName} not found (404), attempting to install...`);
try {
// Try to install the model
await this.pullModel(modelName);
console.log(`[OllamaService] Successfully installed model ${modelName}, retrying warm-up...`);
// Update database to reflect installation
await ollamaModelRepository.updateInstallStatus(modelName, true, false);
// Retry warm-up after installation
const retryResponse = await this._makeRequest(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
messages: [
{ role: 'user', content: 'Hi' }
],
stream: false,
options: {
num_predict: 1,
temperature: 0
}
}),
timeout: this.warmupTimeout
}, `warmup_retry_${modelName}`);
console.log(`[OllamaService] Successfully warmed up model ${modelName} after installation`);
return true;
} catch (installError) {
console.error(`[OllamaService] Failed to auto-install model ${modelName}:`, installError.message);
await ollamaModelRepository.updateInstallStatus(modelName, false, false);
return false;
}
} else {
console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
return false;
}
} }
} }
@ -650,14 +729,8 @@ class OllamaService extends LocalAIServiceBase {
return false; 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) {
@ -802,6 +875,167 @@ class OllamaService extends LocalAIServiceBase {
return models; return models;
} }
async handleGetStatus() {
try {
const installed = await this.isInstalled();
if (!installed) {
return { success: true, installed: false, running: false, models: [] };
}
const running = await this.isServiceRunning();
if (!running) {
return { success: true, installed: true, running: false, models: [] };
}
const models = await this.getAllModelsWithStatus();
return { success: true, installed: true, running: true, models };
} catch (error) {
console.error('[OllamaService] Error getting status:', error);
return { success: false, error: error.message, installed: false, running: false, models: [] };
}
}
async handleInstall() {
try {
const onProgress = (data) => {
this._broadcastToAllWindows('ollama:install-progress', data);
};
await this.autoInstall(onProgress);
if (!await this.isServiceRunning()) {
onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 });
await this.startService();
onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 });
}
this._broadcastToAllWindows('ollama:install-complete', { success: true });
return { success: true };
} catch (error) {
console.error('[OllamaService] Failed to install:', error);
this._broadcastToAllWindows('ollama:install-complete', { success: false, error: error.message });
return { success: false, error: error.message };
}
}
async handleStartService() {
try {
if (!await this.isServiceRunning()) {
console.log('[OllamaService] Starting Ollama service...');
await this.startService();
}
this.emit('install-complete', { success: true });
return { success: true };
} catch (error) {
console.error('[OllamaService] Failed to start service:', error);
this.emit('install-complete', { success: false, error: error.message });
return { success: false, error: error.message };
}
}
async handleEnsureReady() {
try {
if (await this.isInstalled() && !await this.isServiceRunning()) {
console.log('[OllamaService] Ollama installed but not running, starting service...');
await this.startService();
}
return { success: true };
} catch (error) {
console.error('[OllamaService] Failed to ensure ready:', error);
return { success: false, error: error.message };
}
}
async handleGetModels() {
try {
const models = await this.getAllModelsWithStatus();
return { success: true, models };
} catch (error) {
console.error('[OllamaService] Failed to get models:', error);
return { success: false, error: error.message };
}
}
async handleGetModelSuggestions() {
try {
const suggestions = await this.getModelSuggestions();
return { success: true, suggestions };
} catch (error) {
console.error('[OllamaService] Failed to get model suggestions:', error);
return { success: false, error: error.message };
}
}
async handlePullModel(modelName) {
try {
console.log(`[OllamaService] Starting model pull: ${modelName}`);
await ollamaModelRepository.updateInstallStatus(modelName, false, true);
await this.pullModel(modelName);
await ollamaModelRepository.updateInstallStatus(modelName, true, false);
console.log(`[OllamaService] Model ${modelName} pull successful`);
return { success: true };
} catch (error) {
console.error('[OllamaService] Failed to pull model:', error);
await ollamaModelRepository.updateInstallStatus(modelName, false, false);
this._broadcastToAllWindows('ollama:pull-error', { model: modelName, error: error.message });
return { success: false, error: error.message };
}
}
async handleIsModelInstalled(modelName) {
try {
const installed = await this.isModelInstalled(modelName);
return { success: true, installed };
} catch (error) {
console.error('[OllamaService] Failed to check model installation:', error);
return { success: false, error: error.message };
}
}
async handleWarmUpModel(modelName) {
try {
const success = await this.warmUpModel(modelName);
return { success };
} catch (error) {
console.error('[OllamaService] Failed to warm up model:', error);
return { success: false, error: error.message };
}
}
async handleAutoWarmUp() {
try {
const success = await this.autoWarmUpSelectedModel();
return { success };
} catch (error) {
console.error('[OllamaService] Failed to auto warm-up:', error);
return { success: false, error: error.message };
}
}
async handleGetWarmUpStatus() {
try {
const status = this.getWarmUpStatus();
return { success: true, status };
} catch (error) {
console.error('[OllamaService] Failed to get warm-up status:', error);
return { success: false, error: error.message };
}
}
async handleShutdown(force = false) {
try {
console.log(`[OllamaService] Manual shutdown requested (force: ${force})`);
const success = await this.shutdown(force);
return { success };
} catch (error) {
console.error('[OllamaService] Failed to shutdown Ollama:', error);
return { success: false, error: error.message };
}
}
} }
// Export singleton instance // Export singleton instance

View File

@ -0,0 +1,119 @@
const { systemPreferences, shell, desktopCapturer } = require('electron');
const permissionRepository = require('../repositories/permission');
class PermissionService {
async checkSystemPermissions() {
const permissions = {
microphone: 'unknown',
screen: 'unknown',
needsSetup: true
};
try {
if (process.platform === 'darwin') {
const micStatus = systemPreferences.getMediaAccessStatus('microphone');
console.log('[Permissions] Microphone status:', micStatus);
permissions.microphone = micStatus;
const screenStatus = systemPreferences.getMediaAccessStatus('screen');
console.log('[Permissions] Screen status:', screenStatus);
permissions.screen = screenStatus;
permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted';
} else {
permissions.microphone = 'granted';
permissions.screen = 'granted';
permissions.needsSetup = false;
}
console.log('[Permissions] System permissions status:', permissions);
return permissions;
} catch (error) {
console.error('[Permissions] Error checking permissions:', error);
return {
microphone: 'unknown',
screen: 'unknown',
needsSetup: true,
error: error.message
};
}
}
async requestMicrophonePermission() {
if (process.platform !== 'darwin') {
return { success: true };
}
try {
const status = systemPreferences.getMediaAccessStatus('microphone');
console.log('[Permissions] Microphone status:', status);
if (status === 'granted') {
return { success: true, status: 'granted' };
}
const granted = await systemPreferences.askForMediaAccess('microphone');
return {
success: granted,
status: granted ? 'granted' : 'denied'
};
} catch (error) {
console.error('[Permissions] Error requesting microphone permission:', error);
return {
success: false,
error: error.message
};
}
}
async openSystemPreferences(section) {
if (process.platform !== 'darwin') {
return { success: false, error: 'Not supported on this platform' };
}
try {
if (section === 'screen-recording') {
try {
console.log('[Permissions] Triggering screen capture request to register app...');
await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 1, height: 1 }
});
console.log('[Permissions] App registered for screen recording');
} catch (captureError) {
console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message);
}
// await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
}
return { success: true };
} catch (error) {
console.error('[Permissions] Error opening system preferences:', error);
return { success: false, error: error.message };
}
}
async markPermissionsAsCompleted() {
try {
await permissionRepository.markPermissionsAsCompleted();
console.log('[Permissions] Marked permissions as completed');
return { success: true };
} catch (error) {
console.error('[Permissions] Error marking permissions as completed:', error);
return { success: false, error: error.message };
}
}
async checkPermissionsCompleted() {
try {
const completed = await permissionRepository.checkPermissionsCompleted();
console.log('[Permissions] Permissions completed status:', completed);
return completed;
} catch (error) {
console.error('[Permissions] Error checking permissions completed status:', error);
return false;
}
}
}
const permissionService = new PermissionService();
module.exports = permissionService;

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,18 +171,49 @@ 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(modelId) {
try {
console.log(`[WhisperService] Handling download for model: ${modelId}`);
if (!this.isInitialized) {
await this.initialize();
}
await this.ensureModelAvailable(modelId);
return { success: true };
} catch (error) {
console.error(`[WhisperService] Failed to handle download for model ${modelId}:`, error);
return { success: false, error: error.message };
}
}
async handleGetInstalledModels() {
try {
if (!this.isInitialized) {
await this.initialize();
}
const models = await this.getInstalledModels();
return { success: true, models };
} catch (error) {
console.error('[WhisperService] Failed to get installed models:', error);
return { success: false, error: error.message };
}
}
async getModelPath(modelId) { async getModelPath(modelId) {
if (!this.isInitialized || !this.modelsDir) { if (!this.isInitialized || !this.modelsDir) {
@ -448,4 +493,6 @@ class WhisperService extends LocalAIServiceBase {
} }
} }
module.exports = { WhisperService }; // Export singleton instance
const whisperService = new WhisperService();
module.exports = whisperService;

View File

@ -1,8 +1,8 @@
const { BrowserWindow, app } = require('electron'); const { BrowserWindow } = require('electron');
const SttService = require('./stt/sttService'); const SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService'); const SummaryService = require('./summary/summaryService');
const authService = require('../../common/services/authService'); const authService = require('../common/services/authService');
const sessionRepository = require('../../common/repositories/session'); const sessionRepository = require('../common/repositories/session');
const sttRepository = require('./stt/repositories'); const sttRepository = require('./stt/repositories');
class ListenService { class ListenService {
@ -11,8 +11,9 @@ class ListenService {
this.summaryService = new SummaryService(); this.summaryService = new SummaryService();
this.currentSessionId = null; this.currentSessionId = null;
this.isInitializingSession = false; this.isInitializingSession = false;
this.setupServiceCallbacks(); this.setupServiceCallbacks();
console.log('[ListenService] Service instance created.');
} }
setupServiceCallbacks() { setupServiceCallbacks() {
@ -38,11 +39,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;
}
} }
async handleTranscriptionComplete(speaker, text) { async handleTranscriptionComplete(speaker, text) {
@ -158,8 +206,8 @@ class ListenService {
} }
} }
async sendAudioContent(data, mimeType) { async sendMicAudioContent(data, mimeType) {
return await this.sttService.sendAudioContent(data, mimeType); return await this.sttService.sendMicAudioContent(data, mimeType);
} }
async startMacOSAudioCapture() { async startMacOSAudioCapture() {
@ -183,6 +231,8 @@ class ListenService {
// Close STT sessions // Close STT sessions
await this.sttService.closeSessions(); await this.sttService.closeSessions();
await this.stopMacOSAudioCapture();
// End database session // End database session
if (this.currentSessionId) { if (this.currentSessionId) {
await sessionRepository.end(this.currentSessionId); await sessionRepository.end(this.currentSessionId);
@ -193,8 +243,6 @@ class ListenService {
this.currentSessionId = null; this.currentSessionId = null;
this.summaryService.resetConversationHistory(); this.summaryService.resetConversationHistory();
this.sendToRenderer('session-did-close');
console.log('Listen service session closed.'); console.log('Listen service session closed.');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@ -216,88 +264,58 @@ class ListenService {
return this.summaryService.getConversationHistory(); return this.summaryService.getConversationHistory();
} }
setupIpcHandlers() { _createHandler(asyncFn, successMessage, errorMessage) {
const { ipcMain } = require('electron'); return async (...args) => {
ipcMain.handle('is-session-active', async () => {
const isActive = this.isSessionActive();
console.log(`Checking session status. Active: ${isActive}`);
return isActive;
});
ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
const success = await this.initializeSession(language);
return success;
});
ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
try { try {
await this.sendAudioContent(data, mimeType); const result = await asyncFn.apply(this, args);
return { success: true }; if (successMessage) console.log(successMessage);
// `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로,
// 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다.
// 다른 함수들은 이미 success 객체를 반환합니다.
return result && typeof result.success !== 'undefined' ? result : { success: true };
} catch (e) { } catch (e) {
console.error('Error sending user audio:', e); console.error(errorMessage, e);
return { success: false, error: e.message }; return { success: false, error: e.message };
} }
}); };
}
ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => { // `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다.
try { handleSendMicAudioContent = this._createHandler(
await this.sttService.sendSystemAudioContent(data, mimeType); this.sendMicAudioContent,
null,
// Send system audio data back to renderer for AEC reference (like macOS does) 'Error sending user audio:'
this.sendToRenderer('system-audio-data', { data }); );
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('start-macos-audio', async () => { handleStartMacosAudio = this._createHandler(
async () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
return { success: false, error: 'macOS audio capture only available on macOS' }; return { success: false, error: 'macOS audio capture only available on macOS' };
} }
if (this.sttService.isMacOSAudioRunning?.()) { if (this.sttService.isMacOSAudioRunning?.()) {
return { success: false, error: 'already_running' }; return { success: false, error: 'already_running' };
} }
await this.startMacOSAudioCapture();
return { success: true, error: null };
},
'macOS audio capture started.',
'Error starting macOS audio capture:'
);
handleStopMacosAudio = this._createHandler(
this.stopMacOSAudioCapture,
'macOS audio capture stopped.',
'Error stopping macOS audio capture:'
);
try { handleUpdateGoogleSearchSetting = this._createHandler(
const success = await this.startMacOSAudioCapture(); async (enabled) => {
return { success, error: null }; console.log('Google Search setting updated to:', enabled);
} catch (error) { },
console.error('Error starting macOS audio capture:', error); null,
return { success: false, error: error.message }; 'Error updating Google Search setting:'
} );
});
ipcMain.handle('stop-macos-audio', async () => {
try {
this.stopMacOSAudioCapture();
return { success: true };
} catch (error) {
console.error('Error stopping macOS audio capture:', error);
return { success: false, error: error.message };
}
});
// ipcMain.handle('close-session', async () => {
// return await this.closeSession();
// });
ipcMain.handle('update-google-search-setting', async (event, enabled) => {
try {
console.log('Google Search setting updated to:', enabled);
return { success: true };
} catch (error) {
console.error('Error updating Google Search setting:', error);
return { success: false, error: error.message };
}
});
console.log('✅ Listen service IPC handlers registered');
}
} }
module.exports = ListenService; const listenService = new ListenService();
module.exports = listenService;

View File

@ -1,6 +1,6 @@
const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore'); const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../../common/services/firebaseClient'); const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
const transcriptConverter = createEncryptedConverter(['text']); const transcriptConverter = createEncryptedConverter(['text']);

View File

@ -1,6 +1,6 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../../common/services/authService'); const authService = require('../../../common/services/authService');
function getBaseRepository() { function getBaseRepository() {
const user = authService.getCurrentUser(); const user = authService.getCurrentUser();

View File

@ -1,4 +1,4 @@
const sqliteClient = require('../../../../common/services/sqliteClient'); const sqliteClient = require('../../../common/services/sqliteClient');
function addTranscript({ uid, sessionId, speaker, text }) { function addTranscript({ uid, sessionId, speaker, text }) {
// uid is ignored in the SQLite implementation // uid is ignored in the SQLite implementation

View File

@ -1,7 +1,8 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createSTT } = require('../../../common/ai/factory'); const { createSTT } = require('../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager'); const modelStateService = require('../../common/services/modelStateService');
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager');
const COMPLETION_DEBOUNCE_MS = 2000; const COMPLETION_DEBOUNCE_MS = 2000;
@ -34,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 };
}
} }
flushMyCompletion() { flushMyCompletion() {
@ -120,7 +134,7 @@ class SttService {
async initializeSttSessions(language = 'en') { async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
const modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); const modelInfo = modelStateService.getCurrentModelInfo('stt');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
@ -132,6 +146,7 @@ class SttService {
console.log('[SttService] Ignoring message - session already closed'); console.log('[SttService] Ignoring message - session already closed');
return; return;
} }
console.log('[SttService] handleMyMessage', message);
if (this.modelInfo.provider === 'whisper') { if (this.modelInfo.provider === 'whisper') {
// Whisper STT emits 'transcription' events with different structure // Whisper STT emits 'transcription' events with different structure
@ -367,11 +382,6 @@ class SttService {
onclose: event => console.log('Their STT session closed:', event.reason), onclose: event => console.log('Their STT session closed:', event.reason),
}, },
}; };
// Determine auth options for providers that support it
// const authService = require('../../../common/services/authService');
// const userState = authService.getCurrentUser();
// const loggedIn = userState.isLoggedIn;
const sttOptions = { const sttOptions = {
apiKey: this.modelInfo.apiKey, apiKey: this.modelInfo.apiKey,
@ -393,7 +403,7 @@ class SttService {
return true; return true;
} }
async sendAudioContent(data, mimeType) { async sendMicAudioContent(data, mimeType) {
// const provider = await this.getAiProvider(); // const provider = await this.getAiProvider();
// const isGemini = provider === 'gemini'; // const isGemini = provider === 'gemini';
@ -404,7 +414,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
@ -425,7 +435,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
@ -476,8 +486,8 @@ class SttService {
const { app } = require('electron'); const { app } = require('electron');
const path = require('path'); const path = require('path');
const systemAudioPath = app.isPackaged const systemAudioPath = app.isPackaged
? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump') ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'ui', 'assets', 'SystemAudioDump')
: path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump'); : path.join(app.getAppPath(), 'src', 'ui', 'assets', 'SystemAudioDump');
console.log('SystemAudioDump path:', systemAudioPath); console.log('SystemAudioDump path:', systemAudioPath);
@ -506,7 +516,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');

View File

@ -1,7 +1,7 @@
const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore'); const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../../common/services/firebaseClient'); const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
const encryptionService = require('../../../../common/services/encryptionService'); const encryptionService = require('../../../common/services/encryptionService');
const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json']; const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json'];
const summaryConverter = createEncryptedConverter(fieldsToEncrypt); const summaryConverter = createEncryptedConverter(fieldsToEncrypt);

View File

@ -1,6 +1,6 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../../common/services/authService'); const authService = require('../../../common/services/authService');
function getBaseRepository() { function getBaseRepository() {
const user = authService.getCurrentUser(); const user = authService.getCurrentUser();

View File

@ -1,4 +1,4 @@
const sqliteClient = require('../../../../common/services/sqliteClient'); const sqliteClient = require('../../../common/services/sqliteClient');
function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) { function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) {
// uid is ignored in the SQLite implementation // uid is ignored in the SQLite implementation

View File

@ -1,10 +1,10 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { getSystemPrompt } = require('../../../common/prompts/promptBuilder.js'); const { getSystemPrompt } = require('../../common/prompts/promptBuilder.js');
const { createLLM } = require('../../../common/ai/factory'); const { createLLM } = require('../../common/ai/factory');
const authService = require('../../../common/services/authService'); const sessionRepository = require('../../common/repositories/session');
const sessionRepository = require('../../../common/repositories/session');
const summaryRepository = require('./repositories'); const summaryRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager'); const modelStateService = require('../../common/services/modelStateService');
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js');
class SummaryService { class SummaryService {
constructor() { constructor() {
@ -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) {
@ -98,7 +99,7 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId); await sessionRepository.touch(this.currentSessionId);
} }
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); const modelInfo = modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
@ -304,25 +305,20 @@ Keep all points concise and build upon previous analysis if provided.`,
*/ */
async triggerAnalysisIfNeeded() { 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 if (this.onAnalysisComplete) {
if (this.onAnalysisComplete) { this.onAnalysisComplete(data);
this.onAnalysisComplete(data); }
} } else {
} else { console.log('No analysis data returned');
console.log('❌ No analysis data returned from non-blocking call'); }
}
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
} }
} }

View File

@ -1,7 +1,7 @@
const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore'); const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../common/services/firebaseClient'); const { getFirestoreInstance } = require('../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter'); const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter');
const encryptionService = require('../../../common/services/encryptionService'); const encryptionService = require('../../common/services/encryptionService');
const userPresetConverter = createEncryptedConverter(['prompt', 'title']); const userPresetConverter = createEncryptedConverter(['prompt', 'title']);

View File

@ -1,6 +1,6 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService'); const authService = require('../../common/services/authService');
function getBaseRepository() { function getBaseRepository() {
const user = authService.getCurrentUser(); const user = authService.getCurrentUser();

View File

@ -1,4 +1,4 @@
const sqliteClient = require('../../../common/services/sqliteClient'); const sqliteClient = require('../../common/services/sqliteClient');
function getPresets(uid) { function getPresets(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();

View File

@ -1,8 +1,13 @@
const { ipcMain, BrowserWindow } = require('electron'); const { ipcMain, BrowserWindow } = require('electron');
const Store = require('electron-store'); const Store = require('electron-store');
const authService = require('../../common/services/authService'); const authService = require('../common/services/authService');
const settingsRepository = require('./repositories'); const settingsRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../electron/windowManager'); const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager');
// New imports for common services
const modelStateService = require('../common/services/modelStateService');
const ollamaService = require('../common/services/ollamaService');
const whisperService = require('../common/services/whisperService');
const store = new Store({ const store = new Store({
name: 'pickle-glass-settings', name: 'pickle-glass-settings',
@ -19,6 +24,54 @@ const NOTIFICATION_CONFIG = {
RETRY_BASE_DELAY: 1000, // exponential backoff base (ms) RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
}; };
// New facade functions for model state management
async function getModelSettings() {
try {
const [config, storedKeys, selectedModels] = await Promise.all([
modelStateService.getProviderConfig(),
modelStateService.getAllApiKeys(),
modelStateService.getSelectedModels(),
]);
// 동기 함수들은 별도로 호출
const availableLlm = modelStateService.getAvailableModels('llm');
const availableStt = modelStateService.getAvailableModels('stt');
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
} catch (error) {
console.error('[SettingsService] Error getting model settings:', error);
return { success: false, error: error.message };
}
}
async function validateAndSaveKey(provider, key) {
return modelStateService.handleValidateKey(provider, key);
}
async function clearApiKey(provider) {
const success = await modelStateService.handleRemoveApiKey(provider);
return { success };
}
async function setSelectedModel(type, modelId) {
const success = await modelStateService.handleSetSelectedModel(type, modelId);
return { success };
}
// Ollama facade functions
async function getOllamaStatus() {
return ollamaService.getStatus();
}
async function ensureOllamaReady() {
return ollamaService.ensureReady();
}
async function shutdownOllama() {
return ollamaService.shutdown(false); // false for graceful shutdown
}
// window targeting system // window targeting system
class WindowNotificationManager { class WindowNotificationManager {
constructor() { constructor() {
@ -324,6 +377,7 @@ async function removeApiKey() {
} }
}); });
console.log('[SettingsService] API key removed for all providers');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[SettingsService] Error removing API key:', error); console.error('[SettingsService] Error removing API key:', error);
@ -373,57 +427,6 @@ function initialize() {
// cleanup // cleanup
windowNotificationManager.cleanup(); windowNotificationManager.cleanup();
// IPC handlers for settings
ipcMain.handle('settings:getSettings', async () => {
return await getSettings();
});
ipcMain.handle('settings:saveSettings', async (event, settings) => {
return await saveSettings(settings);
});
// IPC handlers for presets
ipcMain.handle('settings:getPresets', async () => {
return await getPresets();
});
ipcMain.handle('settings:getPresetTemplates', async () => {
return await getPresetTemplates();
});
ipcMain.handle('settings:createPreset', async (event, title, prompt) => {
return await createPreset(title, prompt);
});
ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => {
return await updatePreset(id, title, prompt);
});
ipcMain.handle('settings:deletePreset', async (event, id) => {
return await deletePreset(id);
});
ipcMain.handle('settings:saveApiKey', async (event, apiKey, provider) => {
return await saveApiKey(apiKey, provider);
});
ipcMain.handle('settings:removeApiKey', async () => {
return await removeApiKey();
});
ipcMain.handle('settings:updateContentProtection', async (event, enabled) => {
return await updateContentProtection(enabled);
});
ipcMain.handle('settings:get-auto-update', async () => {
return await getAutoUpdateSetting();
});
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => {
console.log('[SettingsService] Setting auto update setting:', isEnabled);
return await setAutoUpdateSetting(isEnabled);
});
console.log('[SettingsService] Initialized and ready.'); console.log('[SettingsService] Initialized and ready.');
} }
@ -455,4 +458,14 @@ module.exports = {
removeApiKey, removeApiKey,
updateContentProtection, updateContentProtection,
getAutoUpdateSetting, getAutoUpdateSetting,
setAutoUpdateSetting,
// Model settings facade
getModelSettings,
validateAndSaveKey,
clearApiKey,
setSelectedModel,
// Ollama facade
getOllamaStatus,
ensureOllamaReady,
shutdownOllama
}; };

View File

@ -0,0 +1 @@
module.exports = require('./sqlite.repository');

View File

@ -0,0 +1,48 @@
const sqliteClient = require('../../common/services/sqliteClient');
const crypto = require('crypto');
function getAllKeybinds() {
const db = sqliteClient.getDb();
const query = 'SELECT * FROM shortcuts';
try {
return db.prepare(query).all();
} catch (error) {
console.error(`[DB] Failed to get keybinds:`, error);
return [];
}
}
function upsertKeybinds(keybinds) {
if (!keybinds || keybinds.length === 0) return;
const db = sqliteClient.getDb();
const upsert = db.transaction((items) => {
const query = `
INSERT INTO shortcuts (action, accelerator, created_at)
VALUES (@action, @accelerator, @created_at)
ON CONFLICT(action) DO UPDATE SET
accelerator = excluded.accelerator;
`;
const insert = db.prepare(query);
for (const item of items) {
insert.run({
action: item.action,
accelerator: item.accelerator,
created_at: Math.floor(Date.now() / 1000)
});
}
});
try {
upsert(keybinds);
} catch (error) {
console.error('[DB] Failed to upsert keybinds:', error);
throw error;
}
}
module.exports = {
getAllKeybinds,
upsertKeybinds
};

View File

@ -0,0 +1,285 @@
const { globalShortcut, screen } = require('electron');
const shortcutsRepository = require('./repositories');
const internalBridge = require('../../bridge/internalBridge');
const askService = require('../ask/askService');
class ShortcutsService {
constructor() {
this.lastVisibleWindows = new Set(['header']);
this.mouseEventsIgnored = false;
this.movementManager = null;
this.windowPool = null;
}
initialize(movementManager, windowPool) {
this.movementManager = movementManager;
this.windowPool = windowPool;
internalBridge.on('reregister-shortcuts', () => {
console.log('[ShortcutsService] Reregistering shortcuts due to header state change.');
this.registerShortcuts();
});
console.log('[ShortcutsService] Initialized with dependencies and event listener.');
}
getDefaultKeybinds() {
const isMac = process.platform === 'darwin';
return {
moveUp: isMac ? 'Cmd+Up' : 'Ctrl+Up',
moveDown: isMac ? 'Cmd+Down' : 'Ctrl+Down',
moveLeft: isMac ? 'Cmd+Left' : 'Ctrl+Left',
moveRight: isMac ? 'Cmd+Right' : 'Ctrl+Right',
toggleVisibility: isMac ? 'Cmd+\\' : 'Ctrl+\\',
toggleClickThrough: isMac ? 'Cmd+M' : 'Ctrl+M',
nextStep: isMac ? 'Cmd+Enter' : 'Ctrl+Enter',
manualScreenshot: isMac ? 'Cmd+Shift+S' : 'Ctrl+Shift+S',
previousResponse: isMac ? 'Cmd+[' : 'Ctrl+[',
nextResponse: isMac ? 'Cmd+]' : 'Ctrl+]',
scrollUp: isMac ? 'Cmd+Shift+Up' : 'Ctrl+Shift+Up',
scrollDown: isMac ? 'Cmd+Shift+Down' : 'Ctrl+Shift+Down',
};
}
async loadKeybinds() {
let keybindsArray = await shortcutsRepository.getAllKeybinds();
if (!keybindsArray || keybindsArray.length === 0) {
console.log(`[Shortcuts] No keybinds found. Loading defaults.`);
const defaults = this.getDefaultKeybinds();
await this.saveKeybinds(defaults);
return defaults;
}
const keybinds = {};
keybindsArray.forEach(k => {
keybinds[k.action] = k.accelerator;
});
const defaults = this.getDefaultKeybinds();
let needsUpdate = false;
for (const action in defaults) {
if (!keybinds[action]) {
keybinds[action] = defaults[action];
needsUpdate = true;
}
}
if (needsUpdate) {
console.log('[Shortcuts] Updating missing keybinds with defaults.');
await this.saveKeybinds(keybinds);
}
return keybinds;
}
async handleSaveShortcuts(newKeybinds) {
try {
await this.saveKeybinds(newKeybinds);
const shortcutEditor = this.windowPool.get('shortcut-settings');
if (shortcutEditor && !shortcutEditor.isDestroyed()) {
shortcutEditor.close(); // This will trigger re-registration on 'closed' event in windowManager
} else {
// If editor wasn't open, re-register immediately
await this.registerShortcuts();
}
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
// On failure, re-register old shortcuts to be safe
await this.registerShortcuts();
return { success: false, error: error.message };
}
}
async handleRestoreDefaults() {
const defaults = this.getDefaultKeybinds();
await this.saveKeybinds(defaults);
await this.registerShortcuts();
return defaults;
}
async saveKeybinds(newKeybinds) {
const keybindsToSave = [];
for (const action in newKeybinds) {
if (Object.prototype.hasOwnProperty.call(newKeybinds, action)) {
keybindsToSave.push({
action: action,
accelerator: newKeybinds[action],
});
}
}
await shortcutsRepository.upsertKeybinds(keybindsToSave);
console.log(`[Shortcuts] Saved keybinds.`);
}
toggleAllWindowsVisibility(windowPool) {
const header = windowPool.get('header');
if (!header) return;
if (header.isVisible()) {
this.lastVisibleWindows.clear();
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed() && win.isVisible()) {
this.lastVisibleWindows.add(name);
}
});
this.lastVisibleWindows.forEach(name => {
if (name === 'header') return;
const win = windowPool.get(name);
if (win && !win.isDestroyed()) win.hide();
});
header.hide();
return;
}
this.lastVisibleWindows.forEach(name => {
const win = windowPool.get(name);
if (win && !win.isDestroyed()) {
win.show();
}
});
}
async registerShortcuts() {
if (!this.movementManager || !this.windowPool) {
console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
return;
}
const keybinds = await this.loadKeybinds();
globalShortcut.unregisterAll();
const header = this.windowPool.get('header');
const mainWindow = header;
const sendToRenderer = (channel, ...args) => {
this.windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
try {
win.webContents.send(channel, ...args);
} catch (e) {
// Ignore errors for destroyed windows
}
}
});
};
sendToRenderer('shortcuts-updated', keybinds);
// --- Hardcoded shortcuts ---
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl';
// Monitor switching
const displays = screen.getAllDisplays();
if (displays.length > 1) {
displays.forEach((display, index) => {
const key = `${modifier}+Shift+${index + 1}`;
globalShortcut.register(key, () => this.movementManager.moveToDisplay(display.id));
});
}
// Edge snapping
const edgeDirections = [
{ key: `${modifier}+Shift+Left`, direction: 'left' },
{ key: `${modifier}+Shift+Right`, direction: 'right' },
];
edgeDirections.forEach(({ key, direction }) => {
globalShortcut.register(key, () => {
if (header && header.isVisible()) this.movementManager.moveToEdge(direction);
});
});
// --- User-configurable shortcuts ---
if (header?.currentHeaderState === 'apikey') {
if (keybinds.toggleVisibility) {
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility(this.windowPool));
}
console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.');
return;
}
for (const action in keybinds) {
const accelerator = keybinds[action];
if (!accelerator) continue;
let callback;
switch(action) {
case 'toggleVisibility':
callback = () => this.toggleAllWindowsVisibility(this.windowPool);
break;
case 'nextStep':
callback = () => askService.toggleAskButton();
break;
case 'scrollUp':
callback = () => {
const askWindow = this.windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
askWindow.webContents.send('scroll-response-up');
}
};
break;
case 'scrollDown':
callback = () => {
const askWindow = this.windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
askWindow.webContents.send('scroll-response-down');
}
};
break;
case 'moveUp':
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('up'); };
break;
case 'moveDown':
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('down'); };
break;
case 'moveLeft':
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('left'); };
break;
case 'moveRight':
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('right'); };
break;
case 'toggleClickThrough':
callback = () => {
this.mouseEventsIgnored = !this.mouseEventsIgnored;
if(mainWindow && !mainWindow.isDestroyed()){
mainWindow.setIgnoreMouseEvents(this.mouseEventsIgnored, { forward: true });
mainWindow.webContents.send('click-through-toggled', this.mouseEventsIgnored);
}
};
break;
case 'manualScreenshot':
callback = () => {
if(mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.executeJavaScript('window.captureManualScreenshot && window.captureManualScreenshot();');
}
};
break;
case 'previousResponse':
callback = () => sendToRenderer('navigate-previous-response');
break;
case 'nextResponse':
callback = () => sendToRenderer('navigate-next-response');
break;
}
if (callback) {
try {
globalShortcut.register(accelerator, callback);
} catch(e) {
console.error(`[Shortcuts] Failed to register shortcut for "${action}" (${accelerator}):`, e.message);
}
}
}
console.log('[Shortcuts] All shortcuts have been registered.');
}
unregisterAll() {
globalShortcut.unregisterAll();
console.log('[Shortcuts] All shortcuts have been unregistered.');
}
}
module.exports = new ShortcutsService();

View File

@ -12,11 +12,11 @@ if (require('electron-squirrel-startup')) {
} }
const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron'); const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
const { createWindows } = require('./electron/windowManager.js'); const { createWindows } = require('./window/windowManager.js');
const ListenService = require('./features/listen/listenService'); const listenService = require('./features/listen/listenService');
const { initializeFirebase } = require('./common/services/firebaseClient'); const { initializeFirebase } = require('./features/common/services/firebaseClient');
const databaseInitializer = require('./common/services/databaseInitializer'); const databaseInitializer = require('./features/common/services/databaseInitializer');
const authService = require('./common/services/authService'); const authService = require('./features/common/services/authService');
const path = require('node:path'); const path = require('node:path');
const express = require('express'); const express = require('express');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
@ -24,27 +24,23 @@ const { autoUpdater } = require('electron-updater');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const askService = require('./features/ask/askService'); const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService'); const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./common/repositories/session'); const sessionRepository = require('./features/common/repositories/session');
const ModelStateService = require('./common/services/modelStateService'); const modelStateService = require('./features/common/services/modelStateService');
const sqliteClient = require('./common/services/sqliteClient'); const featureBridge = require('./bridge/featureBridge');
const windowBridge = require('./bridge/windowBridge');
// Global variables // Global variables
const eventBridge = new EventEmitter(); const eventBridge = new EventEmitter();
let WEB_PORT = 3000; let WEB_PORT = 3000;
let isShuttingDown = false; // Flag to prevent infinite shutdown loop let isShuttingDown = false; // Flag to prevent infinite shutdown loop
const listenService = new ListenService();
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
global.listenService = listenService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService; global.modelStateService = modelStateService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
// Import and initialize OllamaService // Import and initialize OllamaService
const ollamaService = require('./common/services/ollamaService'); const ollamaService = require('./features/common/services/ollamaService');
const ollamaModelRepository = require('./common/repositories/ollamaModel'); const ollamaModelRepository = require('./features/common/repositories/ollamaModel');
// Native deep link handling - cross-platform compatible // Native deep link handling - cross-platform compatible
let pendingDeepLinkUrl = null; let pendingDeepLinkUrl = null;
@ -123,7 +119,7 @@ function setupProtocolHandling() {
} }
function focusMainWindow() { function focusMainWindow() {
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
if (windowPool) { if (windowPool) {
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header && !header.isDestroyed()) { if (header && !header.isDestroyed()) {
@ -202,12 +198,9 @@ app.whenReady().then(async () => {
await modelStateService.initialize(); await modelStateService.initialize();
//////// after_modelStateService //////// //////// after_modelStateService ////////
listenService.setupIpcHandlers(); featureBridge.initialize(); // 추가: featureBridge 초기화
askService.initialize(); windowBridge.initialize();
settingsService.initialize(); setupWebDataHandlers();
setupGeneralIpcHandlers();
setupOllamaIpcHandlers();
setupWhisperIpcHandlers();
// Initialize Ollama models in database // Initialize Ollama models in database
await ollamaModelRepository.initializeDefaultModels(); await ollamaModelRepository.initializeDefaultModels();
@ -248,13 +241,6 @@ app.whenReady().then(async () => {
} }
}); });
app.on('window-all-closed', () => {
listenService.stopMacOSAudioCapture();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', async (event) => { app.on('before-quit', async (event) => {
// Prevent infinite loop by checking if shutdown is already in progress // Prevent infinite loop by checking if shutdown is already in progress
if (isShuttingDown) { if (isShuttingDown) {
@ -272,7 +258,7 @@ app.on('before-quit', async (event) => {
try { try {
// 1. Stop audio capture first (immediate) // 1. Stop audio capture first (immediate)
listenService.stopMacOSAudioCapture(); await listenService.closeSession();
console.log('[Shutdown] Audio capture stopped'); console.log('[Shutdown] Audio capture stopped');
// 2. End all active sessions (database operations) - with error handling // 2. End all active sessions (database operations) - with error handling
@ -328,309 +314,13 @@ app.on('activate', () => {
} }
}); });
function setupWhisperIpcHandlers() {
const { WhisperService } = require('./common/services/whisperService');
const whisperService = new WhisperService();
// Forward download progress events to renderer
whisperService.on('downloadProgress', (data) => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(window => {
window.webContents.send('whisper:download-progress', data);
});
});
// IPC handlers for Whisper operations
ipcMain.handle('whisper:download-model', async (event, modelId) => {
try {
console.log(`[Whisper IPC] Starting download for model: ${modelId}`);
// Ensure WhisperService is initialized first
if (!whisperService.isInitialized) {
console.log('[Whisper IPC] Initializing WhisperService...');
await whisperService.initialize();
}
// Set up progress listener
const progressHandler = (data) => {
if (data.modelId === modelId) {
event.sender.send('whisper:download-progress', data);
}
};
whisperService.on('downloadProgress', progressHandler);
try {
await whisperService.ensureModelAvailable(modelId);
console.log(`[Whisper IPC] Model ${modelId} download completed successfully`);
} finally {
// Cleanup listener
whisperService.removeListener('downloadProgress', progressHandler);
}
return { success: true };
} catch (error) {
console.error(`[Whisper IPC] Failed to download model ${modelId}:`, error);
return { success: false, error: error.message };
}
});
ipcMain.handle('whisper:get-installed-models', async () => {
try {
// Ensure WhisperService is initialized first
if (!whisperService.isInitialized) {
console.log('[Whisper IPC] Initializing WhisperService for model list...');
await whisperService.initialize();
}
const models = await whisperService.getInstalledModels();
return { success: true, models };
} catch (error) {
console.error('[Whisper IPC] Failed to get installed models:', error);
return { success: false, error: error.message };
}
});
}
function setupGeneralIpcHandlers() {
const userRepository = require('./common/repositories/user');
const presetRepository = require('./common/repositories/preset');
ipcMain.handle('get-user-presets', () => {
// The adapter injects the UID.
return presetRepository.getPresets();
});
ipcMain.handle('get-preset-templates', () => {
return presetRepository.getPresetTemplates();
});
ipcMain.handle('start-firebase-auth', async () => {
try {
const authUrl = `http://localhost:${WEB_PORT}/login?mode=electron`;
console.log(`[Auth] Opening Firebase auth URL in browser: ${authUrl}`);
await shell.openExternal(authUrl);
return { success: true };
} catch (error) {
console.error('[Auth] Failed to open Firebase auth URL:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-web-url', () => {
return process.env.pickleglass_WEB_URL || 'http://localhost:3000';
});
ipcMain.handle('get-current-user', () => {
return authService.getCurrentUser();
});
// --- Web UI Data Handlers (New) ---
setupWebDataHandlers();
}
function setupOllamaIpcHandlers() {
// Ollama status and installation
ipcMain.handle('ollama:get-status', async () => {
try {
const installed = await ollamaService.isInstalled();
const running = installed ? await ollamaService.isServiceRunning() : false;
const models = await ollamaService.getAllModelsWithStatus();
return {
installed,
running,
models,
success: true
};
} catch (error) {
console.error('[Ollama IPC] Failed to get status:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('ollama:install', async (event) => {
try {
const onProgress = (data) => {
event.sender.send('ollama:install-progress', data);
};
await ollamaService.autoInstall(onProgress);
if (!await ollamaService.isServiceRunning()) {
onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 });
await ollamaService.startService();
onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 });
}
event.sender.send('ollama:install-complete', { success: true });
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to install:', error);
event.sender.send('ollama:install-complete', { success: false, error: error.message });
return { success: false, error: error.message };
}
});
ipcMain.handle('ollama:start-service', async (event) => {
try {
if (!await ollamaService.isServiceRunning()) {
console.log('[Ollama IPC] Starting Ollama service...');
await ollamaService.startService();
}
event.sender.send('ollama:install-complete', { success: true });
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to start service:', error);
event.sender.send('ollama:install-complete', { success: false, error: error.message });
return { success: false, error: error.message };
}
});
// Ensure Ollama is ready (starts service if installed but not running)
ipcMain.handle('ollama:ensure-ready', async () => {
try {
if (await ollamaService.isInstalled() && !await ollamaService.isServiceRunning()) {
console.log('[Ollama IPC] Ollama installed but not running, starting service...');
await ollamaService.startService();
}
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to ensure ready:', error);
return { success: false, error: error.message };
}
});
// Get all models with their status
ipcMain.handle('ollama:get-models', async () => {
try {
const models = await ollamaService.getAllModelsWithStatus();
return { success: true, models };
} catch (error) {
console.error('[Ollama IPC] Failed to get models:', error);
return { success: false, error: error.message };
}
});
// Get model suggestions for autocomplete
ipcMain.handle('ollama:get-model-suggestions', async () => {
try {
const suggestions = await ollamaService.getModelSuggestions();
return { success: true, suggestions };
} catch (error) {
console.error('[Ollama IPC] Failed to get model suggestions:', error);
return { success: false, error: error.message };
}
});
// Pull/install a specific model
ipcMain.handle('ollama:pull-model', async (event, modelName) => {
try {
console.log(`[Ollama IPC] Starting model pull: ${modelName}`);
// Update DB status to installing
await ollamaModelRepository.updateInstallStatus(modelName, false, true);
// Set up progress listener for real-time updates
const progressHandler = (data) => {
if (data.model === modelName) {
event.sender.send('ollama:pull-progress', data);
}
};
const completeHandler = (data) => {
if (data.model === modelName) {
console.log(`[Ollama IPC] Model ${modelName} pull completed`);
// Clean up listeners
ollamaService.removeListener('pull-progress', progressHandler);
ollamaService.removeListener('pull-complete', completeHandler);
}
};
ollamaService.on('pull-progress', progressHandler);
ollamaService.on('pull-complete', completeHandler);
// Pull the model using REST API
await ollamaService.pullModel(modelName);
// Update DB status to installed
await ollamaModelRepository.updateInstallStatus(modelName, true, false);
console.log(`[Ollama IPC] Model ${modelName} pull successful`);
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to pull model:', error);
// Reset status on error
await ollamaModelRepository.updateInstallStatus(modelName, false, false);
return { success: false, error: error.message };
}
});
// Check if a specific model is installed
ipcMain.handle('ollama:is-model-installed', async (event, modelName) => {
try {
const installed = await ollamaService.isModelInstalled(modelName);
return { success: true, installed };
} catch (error) {
console.error('[Ollama IPC] Failed to check model installation:', error);
return { success: false, error: error.message };
}
});
// Warm up a specific model
ipcMain.handle('ollama:warm-up-model', async (event, modelName) => {
try {
const success = await ollamaService.warmUpModel(modelName);
return { success };
} catch (error) {
console.error('[Ollama IPC] Failed to warm up model:', error);
return { success: false, error: error.message };
}
});
// Auto warm-up currently selected model
ipcMain.handle('ollama:auto-warm-up', async () => {
try {
const success = await ollamaService.autoWarmUpSelectedModel();
return { success };
} catch (error) {
console.error('[Ollama IPC] Failed to auto warm-up:', error);
return { success: false, error: error.message };
}
});
// Get warm-up status for debugging
ipcMain.handle('ollama:get-warm-up-status', async () => {
try {
const status = ollamaService.getWarmUpStatus();
return { success: true, status };
} catch (error) {
console.error('[Ollama IPC] Failed to get warm-up status:', error);
return { success: false, error: error.message };
}
});
// Shutdown Ollama service manually
ipcMain.handle('ollama:shutdown', async (event, force = false) => {
try {
console.log(`[Ollama IPC] Manual shutdown requested (force: ${force})`);
const success = await ollamaService.shutdown(force);
return { success };
} catch (error) {
console.error('[Ollama IPC] Failed to shutdown Ollama:', error);
return { success: false, error: error.message };
}
});
console.log('[Ollama IPC] Handlers registered');
}
function setupWebDataHandlers() { function setupWebDataHandlers() {
const sessionRepository = require('./common/repositories/session'); const sessionRepository = require('./features/common/repositories/session');
const sttRepository = require('./features/listen/stt/repositories'); const sttRepository = require('./features/listen/stt/repositories');
const summaryRepository = require('./features/listen/summary/repositories'); const summaryRepository = require('./features/listen/summary/repositories');
const askRepository = require('./features/ask/repositories'); const askRepository = require('./features/ask/repositories');
const userRepository = require('./common/repositories/user'); const userRepository = require('./features/common/repositories/user');
const presetRepository = require('./common/repositories/preset'); const presetRepository = require('./features/common/repositories/preset');
const handleRequest = async (channel, responseChannel, payload) => { const handleRequest = async (channel, responseChannel, payload) => {
let result; let result;
@ -788,7 +478,7 @@ async function handleCustomUrl(url) {
handlePersonalizeFromUrl(params); handlePersonalizeFromUrl(params);
break; break;
default: default:
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
if (header.isMinimized()) header.restore(); if (header.isMinimized()) header.restore();
@ -806,7 +496,7 @@ async function handleCustomUrl(url) {
} }
async function handleFirebaseAuthCallback(params) { async function handleFirebaseAuthCallback(params) {
const userRepository = require('./common/repositories/user'); const userRepository = require('./features/common/repositories/user');
const { token: idToken } = params; const { token: idToken } = params;
if (!idToken) { if (!idToken) {
@ -842,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.');
@ -850,7 +541,7 @@ async function handleFirebaseAuthCallback(params) {
console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...'); console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...');
// 3. Focus the app window // 3. Focus the app window
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
if (header.isMinimized()) header.restore(); if (header.isMinimized()) header.restore();
@ -863,7 +554,7 @@ async function handleFirebaseAuthCallback(params) {
console.error('[Auth] Error during custom token exchange or sign-in:', error); console.error('[Auth] Error during custom token exchange or sign-in:', error);
// The UI will not change, and the user can try again. // The UI will not change, and the user can try again.
// Optionally, send a generic error event to the renderer. // Optionally, send a generic error event to the renderer.
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
header.webContents.send('auth-failed', { message: error.message }); header.webContents.send('auth-failed', { message: error.message });
@ -874,7 +565,7 @@ async function handleFirebaseAuthCallback(params) {
function handlePersonalizeFromUrl(params) { function handlePersonalizeFromUrl(params) {
console.log('[Custom URL] Personalize params:', params); console.log('[Custom URL] Personalize params:', params);
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {

View File

@ -1,2 +1,296 @@
// See the Electron documentation for details on how to use preload scripts: // src/preload.js
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// Platform information for renderer processes
platform: {
isLinux: process.platform === 'linux',
isMacOS: process.platform === 'darwin',
isWindows: process.platform === 'win32',
platform: process.platform
},
// Common utilities used across multiple components
common: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
// User state listener (used by multiple components)
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
},
// UI Component specific namespaces
// src/ui/app/ApiKeyHeader.js
apiKeyHeader: {
// Model & Provider Management
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
installOllama: () => ipcRenderer.invoke('ollama:install'),
startOllamaService: () => ipcRenderer.invoke('ollama:start-service'),
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'),
// Window Management
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
// Listeners
onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback),
removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback),
onceOllamaInstallComplete: (callback) => ipcRenderer.once('ollama:install-complete', callback),
removeOnceOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback),
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback),
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
// Remove all listeners (for cleanup)
removeAllListeners: () => {
ipcRenderer.removeAllListeners('whisper:download-progress');
ipcRenderer.removeAllListeners('ollama:install-progress');
ipcRenderer.removeAllListeners('ollama:pull-progress');
ipcRenderer.removeAllListeners('ollama:install-complete');
}
},
// src/ui/app/HeaderController.js
headerController: {
// State Management
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
// Window Management
resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
// Permissions
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
checkPermissionsCompleted: () => ipcRenderer.invoke('check-permissions-completed'),
// Listeners
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback),
removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback),
onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback),
removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback)
},
// src/ui/app/MainHeader.js
mainHeader: {
// Window Management
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
sendHeaderAnimationFinished: (state) => ipcRenderer.send('header-animation-finished', state),
// Settings Window Management
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// Generic invoke (for dynamic channel names)
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
// Listeners
onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback),
removeOnListenChangeSessionResult: (callback) => ipcRenderer.removeListener('listen:changeSessionResult', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback)
},
// src/ui/app/PermissionHeader.js
permissionHeader: {
// Permission Management
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed')
},
// src/ui/app/PickleGlassApp.js
pickleGlassApp: {
// Listeners
onClickThroughToggled: (callback) => ipcRenderer.on('click-through-toggled', callback),
removeOnClickThroughToggled: (callback) => ipcRenderer.removeListener('click-through-toggled', callback),
removeAllClickThroughListeners: () => ipcRenderer.removeAllListeners('click-through-toggled')
},
// src/ui/ask/AskView.js
askView: {
// Window Management
closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
// Message Handling
sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
// Listeners
onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback),
onAskStreamError: (callback) => ipcRenderer.on('ask-response-stream-error', callback),
removeOnAskStreamError: (callback) => ipcRenderer.removeListener('ask-response-stream-error', callback),
// Listeners
onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback),
onScrollResponseUp: (callback) => ipcRenderer.on('aks:scrollResponseUp', callback),
removeOnScrollResponseUp: (callback) => ipcRenderer.removeListener('aks:scrollResponseUp', callback),
onScrollResponseDown: (callback) => ipcRenderer.on('aks:scrollResponseDown', callback),
removeOnScrollResponseDown: (callback) => ipcRenderer.removeListener('aks:scrollResponseDown', callback)
},
// src/ui/listen/ListenView.js
listenView: {
// Window Management
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
// Listeners
onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
removeOnSessionStateChanged: (callback) => ipcRenderer.removeListener('session-state-changed', callback)
},
// src/ui/listen/stt/SttView.js
sttView: {
// Listeners
onSttUpdate: (callback) => ipcRenderer.on('stt-update', callback),
removeOnSttUpdate: (callback) => ipcRenderer.removeListener('stt-update', callback)
},
// src/ui/listen/summary/SummaryView.js
summaryView: {
// Message Handling
sendQuestionFromSummary: (text) => ipcRenderer.invoke('ask:sendQuestionFromSummary', text),
// Listeners
onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback),
removeOnSummaryUpdate: (callback) => ipcRenderer.removeListener('summary-update', callback),
removeAllSummaryUpdateListeners: () => ipcRenderer.removeAllListeners('summary-update')
},
// src/ui/settings/SettingsView.js
settingsView: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
openPersonalizePage: () => ipcRenderer.invoke('open-personalize-page'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
// Model & Provider Management
getModelSettings: () => ipcRenderer.invoke('settings:get-model-settings'), // Facade call
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
getAllKeys: () => ipcRenderer.invoke('model:get-all-keys'),
getAvailableModels: (type) => ipcRenderer.invoke('model:get-available-models', type),
getSelectedModels: () => ipcRenderer.invoke('model:get-selected-models'),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
saveApiKey: (key) => ipcRenderer.invoke('model:save-api-key', key),
removeApiKey: (provider) => ipcRenderer.invoke('model:remove-api-key', provider),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
// Ollama Management
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
shutdownOllama: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
// Whisper Management
getWhisperInstalledModels: () => ipcRenderer.invoke('whisper:get-installed-models'),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
// Settings Management
getPresets: () => ipcRenderer.invoke('settings:getPresets'),
getAutoUpdate: () => ipcRenderer.invoke('settings:get-auto-update'),
setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'),
openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'),
// Window Management
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
// Progress Tracking
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
// Listeners
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback),
removeOnSettingsUpdated: (callback) => ipcRenderer.removeListener('settings-updated', callback),
onPresetsUpdated: (callback) => ipcRenderer.on('presets-updated', callback),
removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback)
},
// src/ui/settings/ShortCutSettingsView.js
shortcutSettingsView: {
// Shortcut Management
saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts),
getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'),
closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'),
// Listeners
onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback),
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback)
},
// src/ui/app/content.html inline scripts
content: {
// Animation Management
sendAnimationFinished: () => ipcRenderer.send('animation-finished'),
// Listeners
onWindowShowAnimation: (callback) => ipcRenderer.on('window-show-animation', callback),
removeOnWindowShowAnimation: (callback) => ipcRenderer.removeListener('window-show-animation', callback),
onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback),
removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback),
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),
onListenWindowMoveToCenter: (callback) => ipcRenderer.on('listen-window-move-to-center', callback),
removeOnListenWindowMoveToCenter: (callback) => ipcRenderer.removeListener('listen-window-move-to-center', callback),
onListenWindowMoveToLeft: (callback) => ipcRenderer.on('listen-window-move-to-left', callback),
removeOnListenWindowMoveToLeft: (callback) => ipcRenderer.removeListener('listen-window-move-to-left', callback)
},
// src/ui/listen/audioCore/listenCapture.js
listenCapture: {
// Audio Management
sendMicAudioContent: (data) => ipcRenderer.invoke('listen:sendMicAudio', data),
sendSystemAudioContent: (data) => ipcRenderer.invoke('listen:sendSystemAudio', data),
startMacosSystemAudio: () => ipcRenderer.invoke('listen:startMacosSystemAudio'),
stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
// Session Management
isSessionActive: () => ipcRenderer.invoke('is-session-active'),
// Listeners
onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
removeOnSystemAudioData: (callback) => ipcRenderer.removeListener('system-audio-data', callback)
},
// src/ui/listen/audioCore/renderer.js
renderer: {
// Listeners
onChangeListenCaptureState: (callback) => ipcRenderer.on('change-listen-capture-state', callback),
removeOnChangeListenCaptureState: (callback) => ipcRenderer.removeListener('change-listen-capture-state', callback)
}
});

2081
src/ui/app/ApiKeyHeader.js Normal file

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,15 +25,27 @@ 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.');
} else if (type === 'permission') { } else if (type === 'permission') {
this.permissionHeader = document.createElement('permission-setup'); this.permissionHeader = document.createElement('permission-setup');
this.permissionHeader.continueCallback = () => this.transitionToMainHeader(); this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
@ -48,56 +62,63 @@ 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.require) { if (window.api) {
const { ipcRenderer } = window.require('electron'); window.api.headerController.onUserStateChanged((event, userState) => {
ipcRenderer.on('user-state-changed', (event, userState) => {
console.log('[HeaderController] Received user state change:', userState); console.log('[HeaderController] Received user state change:', userState);
this.handleStateUpdate(userState); this.handleStateUpdate(userState);
}); });
ipcRenderer.on('auth-failed', (event, { message }) => { window.api.headerController.onAuthFailed((event, { message }) => {
console.error('[HeaderController] Received auth failure from main process:', message); console.error('[HeaderController] Received auth failure from main process:', message);
if (this.apiKeyHeader) { if (this.apiKeyHeader) {
this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.'; this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.';
this.apiKeyHeader.isLoading = false; this.apiKeyHeader.isLoading = false;
} }
}); });
ipcRenderer.on('force-show-apikey-header', async () => { window.api.headerController.onForceShowApiKeyHeader(async () => {
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.'); console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
await this._resizeForApiKey(); const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
this.ensureHeader('apikey'); if (!isConfigured) {
}); await this._resizeForWelcome();
this.ensureHeader('welcome');
} else {
await this._resizeForApiKey();
this.ensureHeader('apikey');
}
});
} }
} }
notifyHeaderState(stateOverride) { notifyHeaderState(stateOverride) {
const state = stateOverride || this.currentHeaderType || 'apikey'; const state = stateOverride || this.currentHeaderType || 'apikey';
if (window.require) { if (window.api) {
window.require('electron').ipcRenderer.send('header-state-changed', state); window.api.headerController.sendHeaderStateChanged(state);
} }
} }
async _bootstrap() { async _bootstrap() {
// The initial state will be sent by the main process via 'user-state-changed' // The initial state will be sent by the main process via 'user-state-changed'
// We just need to request it. // We just need to request it.
if (window.require) { if (window.api) {
const userState = await window.require('electron').ipcRenderer.invoke('get-current-user'); const userState = await window.api.common.getCurrentUser();
console.log('[HeaderController] Bootstrapping with initial user state:', userState); console.log('[HeaderController] Bootstrapping with initial user state:', userState);
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');
} }
} }
//////// after_modelStateService //////// //////// after_modelStateService ////////
async handleStateUpdate(userState) { async handleStateUpdate(userState) {
const { ipcRenderer } = window.require('electron'); const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
const isConfigured = await ipcRenderer.invoke('model:are-providers-configured');
if (isConfigured) { if (isConfigured) {
const { isLoggedIn } = userState; const { isLoggedIn } = userState;
@ -112,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() {
@ -126,10 +175,9 @@ class HeaderTransitionManager {
} }
// Check if permissions were previously completed // Check if permissions were previously completed
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron');
try { try {
const permissionsCompleted = await ipcRenderer.invoke('check-permissions-completed'); const permissionsCompleted = await window.api.headerController.checkPermissionsCompleted();
if (permissionsCompleted) { if (permissionsCompleted) {
console.log('[HeaderController] Permissions were previously completed, checking current status...'); console.log('[HeaderController] Permissions were previously completed, checking current status...');
@ -161,39 +209,38 @@ class HeaderTransitionManager {
this.ensureHeader('main'); this.ensureHeader('main');
} }
_resizeForMain() { async _resizeForMain() {
if (!window.require) return; if (!window.api) return;
return window console.log('[HeaderController] _resizeForMain: Resizing window to 353x47');
.require('electron') return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }).catch(() => {});
.ipcRenderer.invoke('resize-header-window', { width: 353, height: 47 })
.catch(() => {});
} }
async _resizeForApiKey() { async _resizeForApiKey(height = 370) {
if (!window.require) return; if (!window.api) return;
return window console.log(`[HeaderController] _resizeForApiKey: Resizing window to 456x${height}`);
.require('electron') return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
.ipcRenderer.invoke('resize-header-window', { width: 350, height: 300 })
.catch(() => {});
} }
async _resizeForPermissionHeader() { async _resizeForPermissionHeader() {
if (!window.require) return; if (!window.api) return;
return window return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 })
.require('electron') .catch(() => {});
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 }) }
async _resizeForWelcome() {
if (!window.api) return;
console.log('[HeaderController] _resizeForWelcome: Resizing window to 456x370');
return window.api.headerController.resizeHeaderWindow({ width: 456, height: 364 })
.catch(() => {}); .catch(() => {});
} }
async checkPermissions() { async checkPermissions() {
if (!window.require) { if (!window.api) {
return { success: true }; return { success: true };
} }
const { ipcRenderer } = window.require('electron');
try { try {
const permissions = await ipcRenderer.invoke('check-system-permissions'); const permissions = await window.api.headerController.checkSystemPermissions();
console.log('[HeaderController] Current permissions:', permissions); console.log('[HeaderController] Current permissions:', permissions);
if (!permissions.needsSetup) { if (!permissions.needsSetup) {

View File

@ -4,8 +4,8 @@ export class MainHeader extends LitElement {
static properties = { static properties = {
// isSessionActive: { type: Boolean, state: true }, // isSessionActive: { type: Boolean, state: true },
isTogglingSession: { type: Boolean, state: true }, isTogglingSession: { type: Boolean, state: true },
actionText: { type: String, state: true },
shortcuts: { type: Object, state: true }, shortcuts: { type: Object, state: true },
listenSessionStatus: { type: String, state: true },
}; };
static styles = css` static styles = css`
@ -348,9 +348,8 @@ export class MainHeader extends LitElement {
this.isAnimating = false; this.isAnimating = false;
this.hasSlidIn = false; this.hasSlidIn = false;
this.settingsHideTimer = null; this.settingsHideTimer = null;
// this.isSessionActive = false;
this.isTogglingSession = false; this.isTogglingSession = false;
this.actionText = 'Listen'; this.listenSessionStatus = 'beforeSession';
this.animationEndTimer = null; this.animationEndTimer = null;
this.handleAnimationEnd = this.handleAnimationEnd.bind(this); this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this); this.handleMouseMove = this.handleMouseMove.bind(this);
@ -359,11 +358,19 @@ export class MainHeader extends LitElement {
this.wasJustDragged = false; this.wasJustDragged = false;
} }
_getListenButtonText(status) {
switch (status) {
case 'beforeSession': return 'Listen';
case 'inSession' : return 'Stop';
case 'afterSession': return 'Done';
default : return 'Listen';
}
}
async handleMouseDown(e) { async handleMouseDown(e) {
e.preventDefault(); e.preventDefault();
const { ipcRenderer } = window.require('electron'); const initialPosition = await window.api.mainHeader.getHeaderPosition();
const initialPosition = await ipcRenderer.invoke('get-header-position');
this.dragState = { this.dragState = {
initialMouseX: e.screenX, initialMouseX: e.screenX,
@ -390,8 +397,7 @@ export class MainHeader extends LitElement {
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX); const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY); const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
const { ipcRenderer } = window.require('electron'); window.api.mainHeader.moveHeaderTo(newWindowX, newWindowY);
ipcRenderer.invoke('move-header-to', newWindowX, newWindowY);
} }
handleMouseUp(e) { handleMouseUp(e) {
@ -447,12 +453,12 @@ export class MainHeader extends LitElement {
if (this.classList.contains('hiding')) { if (this.classList.contains('hiding')) {
this.classList.add('hidden'); this.classList.add('hidden');
if (window.require) { if (window.api) {
window.require('electron').ipcRenderer.send('header-animation-finished', 'hidden'); window.api.mainHeader.sendHeaderAnimationFinished('hidden');
} }
} else if (this.classList.contains('showing')) { } else if (this.classList.contains('showing')) {
if (window.require) { if (window.api) {
window.require('electron').ipcRenderer.send('header-animation-finished', 'visible'); window.api.mainHeader.sendHeaderAnimationFinished('visible');
} }
} }
} }
@ -466,26 +472,27 @@ export class MainHeader extends LitElement {
super.connectedCallback(); super.connectedCallback();
this.addEventListener('animationend', this.handleAnimationEnd); this.addEventListener('animationend', this.handleAnimationEnd);
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron');
this._sessionStateTextListener = (event, text) => { this._sessionStateTextListener = (event, { success }) => {
this.actionText = text; if (success) {
this.isTogglingSession = false; this.listenSessionStatus = ({
beforeSession: 'inSession',
inSession: 'afterSession',
afterSession: 'beforeSession',
})[this.listenSessionStatus] || 'beforeSession';
} else {
this.listenSessionStatus = 'beforeSession';
}
this.isTogglingSession = false; // ✨ 로딩 상태만 해제
}; };
ipcRenderer.on('session-state-text', this._sessionStateTextListener); window.api.mainHeader.onListenChangeSessionResult(this._sessionStateTextListener);
// this._sessionStateListener = (event, { isActive }) => {
// this.isSessionActive = isActive;
// this.isTogglingSession = false;
// };
// ipcRenderer.on('session-state-changed', this._sessionStateListener);
this._shortcutListener = (event, keybinds) => { this._shortcutListener = (event, keybinds) => {
console.log('[MainHeader] Received updated shortcuts:', keybinds); console.log('[MainHeader] Received updated shortcuts:', keybinds);
this.shortcuts = keybinds; this.shortcuts = keybinds;
}; };
ipcRenderer.on('shortcuts-updated', this._shortcutListener); window.api.mainHeader.onShortcutsUpdated(this._shortcutListener);
} }
} }
@ -498,39 +505,34 @@ export class MainHeader extends LitElement {
this.animationEndTimer = null; this.animationEndTimer = null;
} }
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron');
if (this._sessionStateTextListener) { if (this._sessionStateTextListener) {
ipcRenderer.removeListener('session-state-text', this._sessionStateTextListener); window.api.mainHeader.removeOnListenChangeSessionResult(this._sessionStateTextListener);
} }
// if (this._sessionStateListener) {
// ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
// }
if (this._shortcutListener) { if (this._shortcutListener) {
ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener); window.api.mainHeader.removeOnShortcutsUpdated(this._shortcutListener);
} }
} }
} }
invoke(channel, ...args) { invoke(channel, ...args) {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
if (window.require) { if (window.api) {
window.require('electron').ipcRenderer.invoke(channel, ...args); window.api.mainHeader.invoke(channel, ...args);
} }
// return Promise.resolve(); // return Promise.resolve();
} }
showSettingsWindow(element) { showSettingsWindow(element) {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron');
console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`); console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`);
ipcRenderer.send('cancel-hide-settings-window'); window.api.mainHeader.cancelHideSettingsWindow();
if (element) { if (element) {
const { left, top, width, height } = element.getBoundingClientRect(); const { left, top, width, height } = element.getBoundingClientRect();
ipcRenderer.send('show-settings-window', { window.api.mainHeader.showSettingsWindow({
x: left, x: left,
y: top, y: top,
width, width,
@ -542,9 +544,9 @@ export class MainHeader extends LitElement {
hideSettingsWindow() { hideSettingsWindow() {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
if (window.require) { if (window.api) {
console.log(`[MainHeader] hideSettingsWindow called at ${Date.now()}`); console.log(`[MainHeader] hideSettingsWindow called at ${Date.now()}`);
window.require('electron').ipcRenderer.send('hide-settings-window'); window.api.mainHeader.hideSettingsWindow();
} }
} }
@ -557,15 +559,26 @@ export class MainHeader extends LitElement {
this.isTogglingSession = true; this.isTogglingSession = true;
try { try {
const channel = 'toggle-feature'; const channel = 'listen:changeSession';
const args = ['listen']; const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
await this.invoke(channel, ...args); await this.invoke(channel, listenButtonText);
} catch (error) { } catch (error) {
console.error('IPC invoke for session toggle failed:', error); console.error('IPC invoke for session change failed:', error);
this.isTogglingSession = false; this.isTogglingSession = false;
} }
} }
async _handleAskClick() {
if (this.wasJustDragged) return;
try {
const channel = 'ask:toggleAskButton';
await this.invoke(channel);
} catch (error) {
console.error('IPC invoke for ask button failed:', error);
}
}
renderShortcut(accelerator) { renderShortcut(accelerator) {
if (!accelerator) return html``; if (!accelerator) return html``;
@ -591,11 +604,13 @@ export class MainHeader extends LitElement {
} }
render() { render() {
const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
const buttonClasses = { const buttonClasses = {
active: this.actionText === 'Stop', active: listenButtonText === 'Stop',
done: this.actionText === 'Done', done: listenButtonText === 'Done',
}; };
const showStopIcon = this.actionText === 'Stop' || this.actionText === 'Done'; const showStopIcon = listenButtonText === 'Stop' || listenButtonText === 'Done';
return html` return html`
<div class="header" @mousedown=${this.handleMouseDown}> <div class="header" @mousedown=${this.handleMouseDown}>
@ -612,7 +627,7 @@ export class MainHeader extends LitElement {
` `
: html` : html`
<div class="action-text"> <div class="action-text">
<div class="action-text-content">${this.actionText}</div> <div class="action-text-content">${listenButtonText}</div>
</div> </div>
<div class="listen-icon"> <div class="listen-icon">
${showStopIcon ${showStopIcon
@ -632,7 +647,7 @@ export class MainHeader extends LitElement {
`} `}
</button> </button>
<div class="header-actions ask-action" @click=${() => this.invoke('toggle-feature', 'ask')}> <div class="header-actions ask-action" @click=${() => this._handleAskClick()}>
<div class="action-text"> <div class="action-text">
<div class="action-text-content">Ask</div> <div class="action-text-content">Ask</div>
</div> </div>

View File

@ -288,13 +288,12 @@ export class PermissionHeader extends LitElement {
} }
async checkPermissions() { async checkPermissions() {
if (!window.require || this.isChecking) return; if (!window.api || this.isChecking) return;
this.isChecking = true; this.isChecking = true;
const { ipcRenderer } = window.require('electron');
try { try {
const permissions = await ipcRenderer.invoke('check-system-permissions'); const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Permission check result:', permissions); console.log('[PermissionHeader] Permission check result:', permissions);
const prevMic = this.microphoneGranted; const prevMic = this.microphoneGranted;
@ -324,13 +323,12 @@ export class PermissionHeader extends LitElement {
} }
async handleMicrophoneClick() { async handleMicrophoneClick() {
if (!window.require || this.microphoneGranted === 'granted') return; if (!window.api || this.microphoneGranted === 'granted') return;
console.log('[PermissionHeader] Requesting microphone permission...'); console.log('[PermissionHeader] Requesting microphone permission...');
const { ipcRenderer } = window.require('electron');
try { try {
const result = await ipcRenderer.invoke('check-system-permissions'); const result = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Microphone permission result:', result); console.log('[PermissionHeader] Microphone permission result:', result);
if (result.microphone === 'granted') { if (result.microphone === 'granted') {
@ -340,7 +338,7 @@ export class PermissionHeader extends LitElement {
} }
if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') { if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
const res = await ipcRenderer.invoke('request-microphone-permission'); const res = await window.api.permissionHeader.requestMicrophonePermission();
if (res.status === 'granted' || res.success === true) { if (res.status === 'granted' || res.success === true) {
this.microphoneGranted = 'granted'; this.microphoneGranted = 'granted';
this.requestUpdate(); this.requestUpdate();
@ -357,13 +355,12 @@ export class PermissionHeader extends LitElement {
} }
async handleScreenClick() { async handleScreenClick() {
if (!window.require || this.screenGranted === 'granted') return; if (!window.api || this.screenGranted === 'granted') return;
console.log('[PermissionHeader] Checking screen recording permission...'); console.log('[PermissionHeader] Checking screen recording permission...');
const { ipcRenderer } = window.require('electron');
try { try {
const permissions = await ipcRenderer.invoke('check-system-permissions'); const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Screen permission check result:', permissions); console.log('[PermissionHeader] Screen permission check result:', permissions);
if (permissions.screen === 'granted') { if (permissions.screen === 'granted') {
@ -373,7 +370,7 @@ export class PermissionHeader extends LitElement {
} }
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') { if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
console.log('[PermissionHeader] Opening screen recording preferences...'); console.log('[PermissionHeader] Opening screen recording preferences...');
await ipcRenderer.invoke('open-system-preferences', 'screen-recording'); await window.api.permissionHeader.openSystemPreferences('screen-recording');
} }
// Check permissions again after a delay // Check permissions again after a delay
@ -389,10 +386,9 @@ export class PermissionHeader extends LitElement {
this.microphoneGranted === 'granted' && this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted') { this.screenGranted === 'granted') {
// Mark permissions as completed // Mark permissions as completed
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron');
try { try {
await ipcRenderer.invoke('mark-permissions-completed'); await window.api.permissionHeader.markPermissionsCompleted();
console.log('[PermissionHeader] Marked permissions as completed'); console.log('[PermissionHeader] Marked permissions as completed');
} catch (error) { } catch (error) {
console.error('[PermissionHeader] Error marking permissions as completed:', error); console.error('[PermissionHeader] Error marking permissions as completed:', error);
@ -405,8 +401,8 @@ export class PermissionHeader extends LitElement {
handleClose() { handleClose() {
console.log('Close button clicked'); console.log('Close button clicked');
if (window.require) { if (window.api) {
window.require('electron').ipcRenderer.invoke('quit-application'); window.api.common.quitApplication();
} }
} }

View File

@ -1,10 +1,10 @@
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 { SettingsView } from '../features/settings/SettingsView.js'; import { SettingsView } from '../settings/SettingsView.js';
import { AssistantView } from '../features/listen/AssistantView.js'; import { ListenView } from '../listen/ListenView.js';
import { AskView } from '../features/ask/AskView.js'; import { AskView } from '../ask/AskView.js';
import { ShortcutSettingsView } from '../features/settings/ShortCutSettingsView.js'; import { ShortcutSettingsView } from '../settings/ShortCutSettingsView.js';
import '../features/listen/renderer/renderer.js'; import '../listen/audioCore/renderer.js';
export class PickleGlassApp extends LitElement { export class PickleGlassApp extends LitElement {
static styles = css` static styles = css`
@ -17,7 +17,7 @@ export class PickleGlassApp extends LitElement {
border-radius: 7px; border-radius: 7px;
} }
assistant-view { listen-view {
display: block; display: block;
width: 100%; width: 100%;
height: 100%; height: 100%;
@ -74,33 +74,21 @@ export class PickleGlassApp extends LitElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron'); window.api.pickleGlassApp.onClickThroughToggled((_, isEnabled) => {
ipcRenderer.on('click-through-toggled', (_, isEnabled) => {
this._isClickThrough = isEnabled; this._isClickThrough = isEnabled;
}); });
// ipcRenderer.on('start-listening-session', () => {
// console.log('Received start-listening-session command, calling handleListenClick.');
// this.handleListenClick();
// });
} }
} }
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron'); window.api.pickleGlassApp.removeAllClickThroughListeners();
ipcRenderer.removeAllListeners('click-through-toggled');
// ipcRenderer.removeAllListeners('start-listening-session');
} }
} }
updated(changedProperties) { updated(changedProperties) {
// if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) {
// this.requestWindowResize();
// }
if (changedProperties.has('currentView')) { if (changedProperties.has('currentView')) {
const viewContainer = this.shadowRoot?.querySelector('.view-container'); const viewContainer = this.shadowRoot?.querySelector('.view-container');
if (viewContainer) { if (viewContainer) {
@ -129,40 +117,9 @@ export class PickleGlassApp extends LitElement {
} }
} }
// async handleListenClick() {
// if (window.require) {
// const { ipcRenderer } = window.require('electron');
// const isActive = await ipcRenderer.invoke('is-session-active');
// // if (isActive) {
// // console.log('Session is already active. No action needed.');
// // return;
// // }
// }
// if (window.pickleGlass) {
// // await window.pickleGlass.initializeopenai(this.selectedProfile, this.selectedLanguage);
// window.pickleGlass.startCapture(this.selectedScreenshotInterval, this.selectedImageQuality);
// }
// // 🔄 Clear previous summary/analysis when a new listening session begins
// this.structuredData = {
// summary: [],
// topic: { header: '', bullets: [] },
// actions: [],
// followUps: [],
// };
// this.currentResponseIndex = -1;
// this.startTime = Date.now();
// this.currentView = 'listen';
// this.isMainViewVisible = true;
// }
async handleClose() { async handleClose() {
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron'); await window.api.common.quitApplication();
await ipcRenderer.invoke('quit-application');
} }
} }
@ -172,12 +129,12 @@ export class PickleGlassApp extends LitElement {
render() { render() {
switch (this.currentView) { switch (this.currentView) {
case 'listen': case 'listen':
return html`<assistant-view return html`<listen-view
.currentResponseIndex=${this.currentResponseIndex} .currentResponseIndex=${this.currentResponseIndex}
.selectedProfile=${this.selectedProfile} .selectedProfile=${this.selectedProfile}
.structuredData=${this.structuredData} .structuredData=${this.structuredData}
@response-index-changed=${e => (this.currentResponseIndex = e.detail.index)} @response-index-changed=${e => (this.currentResponseIndex = e.detail.index)}
></assistant-view>`; ></listen-view>`;
case 'ask': case 'ask':
return html`<ask-view></ask-view>`; return html`<ask-view></ask-view>`;
case 'settings': case 'settings':

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,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" /> <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
<title>Pickle Glass Content</title> <title>Pickle Glass Content</title>
<style> <style>
:root { :root {
@ -230,7 +230,7 @@
<body> <body>
<script src="../assets/marked-4.3.0.min.js"></script> <script src="../assets/marked-4.3.0.min.js"></script>
<script type="module" src="../../public/build/content.js"></script> <script type="module" src="../../../public/build/content.js"></script>
<pickle-glass-app id="pickle-glass"></pickle-glass-app> <pickle-glass-app id="pickle-glass"></pickle-glass-app>
@ -238,15 +238,13 @@
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('pickle-glass'); const app = document.getElementById('pickle-glass');
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron');
// --- REFACTORED: Event-driven animation handling --- // --- REFACTORED: Event-driven animation handling ---
app.addEventListener('animationend', (event) => { app.addEventListener('animationend', (event) => {
// 숨김 애니메이션이 끝나면 main 프로세스에 알려 창을 실제로 숨깁니다. // 숨김 애니메이션이 끝나면 main 프로세스에 알려 창을 실제로 숨깁니다.
if (event.animationName === 'slideUpToHeader' || event.animationName === 'settingsCollapseToButton') { if (event.animationName === 'slideUpToHeader' || event.animationName === 'settingsCollapseToButton') {
console.log(`Animation finished: ${event.animationName}. Notifying main process.`); console.log(`Animation finished: ${event.animationName}. Notifying main process.`);
ipcRenderer.send('animation-finished'); window.api.content.sendAnimationFinished();
// 완료 후 애니메이션 클래스 정리 // 완료 후 애니메이션 클래스 정리
app.classList.remove('window-sliding-up', 'settings-window-hide'); app.classList.remove('window-sliding-up', 'settings-window-hide');
@ -257,26 +255,26 @@
} }
}); });
ipcRenderer.on('window-show-animation', () => { window.api.content.onWindowShowAnimation(() => {
console.log('Starting window show animation'); console.log('Starting window show animation');
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide'); app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
app.classList.add('window-sliding-down'); app.classList.add('window-sliding-down');
}); });
ipcRenderer.on('window-hide-animation', () => { window.api.content.onWindowHideAnimation(() => {
console.log('Starting window hide animation'); console.log('Starting window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show'); app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('window-sliding-up'); app.classList.add('window-sliding-up');
}); });
ipcRenderer.on('settings-window-hide-animation', () => { window.api.content.onSettingsWindowHideAnimation(() => {
console.log('Starting settings window hide animation'); console.log('Starting settings window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show'); app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('settings-window-hide'); app.classList.add('settings-window-hide');
}); });
// --- UNCHANGED: Existing logic for listen window movement --- // --- UNCHANGED: Existing logic for listen window movement ---
ipcRenderer.on('listen-window-move-to-center', () => { window.api.content.onListenWindowMoveToCenter(() => {
console.log('Moving listen window to center'); console.log('Moving listen window to center');
app.classList.add('listen-window-moving'); app.classList.add('listen-window-moving');
app.classList.remove('listen-window-left'); app.classList.remove('listen-window-left');
@ -287,7 +285,7 @@
}, 350); }, 350);
}); });
ipcRenderer.on('listen-window-move-to-left', () => { window.api.content.onListenWindowMoveToLeft(() => {
console.log('Moving listen window to left'); console.log('Moving listen window to left');
app.classList.add('listen-window-moving'); app.classList.add('listen-window-moving');
app.classList.remove('listen-window-center'); app.classList.remove('listen-window-center');

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" /> <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
<title>Pickle Glass Header</title> <title>Pickle Glass Header</title>
<style> <style>
html, html,
@ -17,7 +17,7 @@
<div id="header-container" tabindex="0" style="outline: none;"> <div id="header-container" tabindex="0" style="outline: none;">
</div> </div>
<script type="module" src="../../public/build/header.js"></script> <script type="module" src="../../../public/build/header.js"></script>
<script> <script>
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') { if (params.get('glass') === 'true') {

View File

@ -1,4 +1,4 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js'; import { html, css, LitElement } from '../../ui/assets/lit-core-2.7.4.min.js';
export class AskView extends LitElement { export class AskView extends LitElement {
static properties = { static properties = {
@ -719,28 +719,21 @@ export class AskView extends LitElement {
this.headerText = 'AI Response'; this.headerText = 'AI Response';
this.headerAnimating = false; this.headerAnimating = false;
this.isStreaming = false; this.isStreaming = false;
this.accumulatedResponse = '';
this.marked = null; this.marked = null;
this.hljs = null; this.hljs = null;
this.DOMPurify = null; this.DOMPurify = null;
this.isLibrariesLoaded = false; this.isLibrariesLoaded = false;
this.handleStreamChunk = this.handleStreamChunk.bind(this);
this.handleStreamEnd = this.handleStreamEnd.bind(this);
this.handleSendText = this.handleSendText.bind(this); this.handleSendText = this.handleSendText.bind(this);
this.handleGlobalSendRequest = this.handleGlobalSendRequest.bind(this);
this.handleTextKeydown = this.handleTextKeydown.bind(this); this.handleTextKeydown = this.handleTextKeydown.bind(this);
this.closeResponsePanel = this.closeResponsePanel.bind(this);
this.handleCopy = this.handleCopy.bind(this); this.handleCopy = this.handleCopy.bind(this);
this.clearResponseContent = this.clearResponseContent.bind(this); this.clearResponseContent = this.clearResponseContent.bind(this);
this.processAssistantQuestion = this.processAssistantQuestion.bind(this);
this.handleToggleTextInput = this.handleToggleTextInput.bind(this);
this.handleEscKey = this.handleEscKey.bind(this); this.handleEscKey = this.handleEscKey.bind(this);
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleScroll = this.handleScroll.bind(this); this.handleScroll = this.handleScroll.bind(this);
this.handleCloseAskWindow = this.handleCloseAskWindow.bind(this);
this.handleCloseIfNoContent = this.handleCloseIfNoContent.bind(this);
this.loadLibraries(); this.loadLibraries();
@ -748,6 +741,86 @@ export class AskView extends LitElement {
this.isThrottled = false; this.isThrottled = false;
} }
connectedCallback() {
super.connectedCallback();
console.log('📱 AskView connectedCallback - IPC 이벤트 리스너 설정');
document.addEventListener('keydown', this.handleEscKey);
this.resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const needed = entry.contentRect.height;
const current = window.innerHeight;
if (needed > current - 4) {
this.requestWindowResize(Math.ceil(needed));
}
}
});
const container = this.shadowRoot?.querySelector('.ask-container');
if (container) this.resizeObserver.observe(container);
this.handleQuestionFromAssistant = (event, question) => {
console.log('📨 AskView: Received question from ListenView:', question);
this.handleSendText(null, question);
};
if (window.api) {
window.api.askView.onShowTextInput(() => {
console.log('📤 Show text input signal received');
if (!this.showTextInput) {
this.showTextInput = true;
this.requestUpdate();
}
});
window.api.askView.onScrollResponseUp(() => this.handleScroll('up'));
window.api.askView.onScrollResponseDown(() => this.handleScroll('down'));
window.api.askView.onAskStateUpdate((event, newState) => {
this.currentResponse = newState.currentResponse;
this.currentQuestion = newState.currentQuestion;
this.isLoading = newState.isLoading;
this.isStreaming = newState.isStreaming;
this.showTextInput = newState.showTextInput;
});
console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
console.log('📱 AskView disconnectedCallback - IPC 이벤트 리스너 제거');
document.removeEventListener('keydown', this.handleEscKey);
if (this.copyTimeout) {
clearTimeout(this.copyTimeout);
}
if (this.headerAnimationTimeout) {
clearTimeout(this.headerAnimationTimeout);
}
if (this.streamingTimeout) {
clearTimeout(this.streamingTimeout);
}
Object.values(this.lineCopyTimeouts).forEach(timeout => clearTimeout(timeout));
if (window.api) {
window.api.askView.removeOnAskStateUpdate(this.handleAskStateUpdate);
window.api.askView.removeOnShowTextInput(this.handleShowTextInput);
window.api.askView.removeOnScrollResponseUp(this.handleScroll);
window.api.askView.removeOnScrollResponseDown(this.handleScroll);
console.log('✅ AskView: IPC 이벤트 리스너 제거 필요');
}
}
async loadLibraries() { async loadLibraries() {
try { try {
if (!window.marked) { if (!window.marked) {
@ -804,38 +877,46 @@ export class AskView extends LitElement {
} }
} }
handleDocumentClick(e) { handleCloseAskWindow() {
this.clearResponseContent();
window.api.askView.closeAskWindow();
}
handleCloseIfNoContent() {
if (!this.currentResponse && !this.isLoading && !this.isStreaming) { if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
const askContainer = this.shadowRoot?.querySelector('.ask-container'); this.handleCloseAskWindow();
if (askContainer && !e.composedPath().includes(askContainer)) {
this.closeIfNoContent();
}
} }
} }
handleEscKey(e) { handleEscKey(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
this.closeResponsePanel(); this.handleCloseIfNoContent();
} }
} }
handleWindowBlur() { clearResponseContent() {
if (!this.currentResponse && !this.isLoading && !this.isStreaming) { this.currentResponse = '';
// If there's no active content, ask the main process to close this window. this.currentQuestion = '';
if (window.require) { this.isLoading = false;
const { ipcRenderer } = window.require('electron'); this.isStreaming = false;
ipcRenderer.invoke('close-ask-window-if-empty'); this.headerText = 'AI Response';
this.showTextInput = true;
}
handleInputFocus() {
this.isInputFocused = true;
}
focusTextInput() {
requestAnimationFrame(() => {
const textInput = this.shadowRoot?.getElementById('textInput');
if (textInput) {
textInput.focus();
} }
} });
} }
closeIfNoContent() {
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('force-close-window', 'ask');
}
}
loadScript(src) { loadScript(src) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -875,125 +956,6 @@ export class AskView extends LitElement {
return text; return text;
} }
connectedCallback() {
super.connectedCallback();
console.log('📱 AskView connectedCallback - IPC 이벤트 리스너 설정');
document.addEventListener('click', this.handleDocumentClick, true);
document.addEventListener('keydown', this.handleEscKey);
this.resizeObserver = new ResizeObserver(entries => {
for (const entry of entries) {
const needed = entry.contentRect.height;
const current = window.innerHeight;
if (needed > current - 4) {
this.requestWindowResize(Math.ceil(needed));
}
}
});
const container = this.shadowRoot?.querySelector('.ask-container');
if (container) this.resizeObserver.observe(container);
this.handleQuestionFromAssistant = (event, question) => {
console.log('📨 AskView: Received question from AssistantView:', question);
this.currentResponse = '';
this.isStreaming = false;
this.requestUpdate();
this.currentQuestion = question;
this.isLoading = true;
this.showTextInput = false;
this.headerText = 'analyzing screen...';
this.startHeaderAnimation();
this.requestUpdate();
this.processAssistantQuestion(question);
};
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('ask-global-send', this.handleGlobalSendRequest);
ipcRenderer.on('toggle-text-input', this.handleToggleTextInput);
ipcRenderer.on('receive-question-from-assistant', this.handleQuestionFromAssistant);
ipcRenderer.on('hide-text-input', () => {
console.log('📤 Hide text input signal received');
this.showTextInput = false;
this.requestUpdate();
});
ipcRenderer.on('clear-ask-response', () => {
console.log('📤 Clear response signal received');
this.currentResponse = '';
this.isStreaming = false;
this.isLoading = false;
this.headerText = 'AI Response';
this.requestUpdate();
});
ipcRenderer.on('window-hide-animation', () => {
console.log('📤 Ask window hiding - clearing response content');
setTimeout(() => {
this.clearResponseContent();
}, 250);
});
ipcRenderer.on('window-blur', this.handleWindowBlur);
ipcRenderer.on('window-did-show', () => {
if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
this.focusTextInput();
}
});
ipcRenderer.on('ask-response-chunk', this.handleStreamChunk);
ipcRenderer.on('ask-response-stream-end', this.handleStreamEnd);
ipcRenderer.on('scroll-response-up', () => this.handleScroll('up'));
ipcRenderer.on('scroll-response-down', () => this.handleScroll('down'));
console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
}
}
disconnectedCallback() {
super.disconnectedCallback();
this.resizeObserver?.disconnect();
console.log('📱 AskView disconnectedCallback - IPC 이벤트 리스너 제거');
document.removeEventListener('click', this.handleDocumentClick, true);
document.removeEventListener('keydown', this.handleEscKey);
if (this.copyTimeout) {
clearTimeout(this.copyTimeout);
}
if (this.headerAnimationTimeout) {
clearTimeout(this.headerAnimationTimeout);
}
if (this.streamingTimeout) {
clearTimeout(this.streamingTimeout);
}
Object.values(this.lineCopyTimeouts).forEach(timeout => clearTimeout(timeout));
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeListener('ask-global-send', this.handleGlobalSendRequest);
ipcRenderer.removeListener('toggle-text-input', this.handleToggleTextInput);
ipcRenderer.removeListener('clear-ask-response', () => { });
ipcRenderer.removeListener('hide-text-input', () => { });
ipcRenderer.removeListener('window-hide-animation', () => { });
ipcRenderer.removeListener('window-blur', this.handleWindowBlur);
ipcRenderer.removeListener('ask-response-chunk', this.handleStreamChunk);
ipcRenderer.removeListener('ask-response-stream-end', this.handleStreamEnd);
ipcRenderer.removeListener('scroll-response-up', () => this.handleScroll('up'));
ipcRenderer.removeListener('scroll-response-down', () => this.handleScroll('down'));
console.log('✅ AskView: IPC 이벤트 리스너 제거 완료');
}
}
handleScroll(direction) { handleScroll(direction) {
const scrollableElement = this.shadowRoot.querySelector('#responseContainer'); const scrollableElement = this.shadowRoot.querySelector('#responseContainer');
if (scrollableElement) { if (scrollableElement) {
@ -1006,56 +968,26 @@ export class AskView extends LitElement {
} }
} }
// --- 스트리밍 처리 핸들러 ---
handleStreamChunk(event, { token }) {
if (!this.isStreaming) {
this.isStreaming = true;
this.isLoading = false;
this.accumulatedResponse = '';
const container = this.shadowRoot.getElementById('responseContainer');
if (container) container.innerHTML = '';
this.headerText = 'AI Response';
this.headerAnimating = false;
this.requestUpdate();
}
this.accumulatedResponse += token;
this.renderContent();
}
handleStreamEnd() {
this.isStreaming = false;
this.currentResponse = this.accumulatedResponse;
if (this.headerText !== 'AI Response') {
this.headerText = 'AI Response';
this.requestUpdate();
}
this.renderContent();
}
// ✨ 렌더링 로직 통합
renderContent() { renderContent() {
if (!this.isLoading && !this.isStreaming && !this.currentResponse) {
const responseContainer = this.shadowRoot.getElementById('responseContainer');
if (responseContainer) responseContainer.innerHTML = '<div class="empty-state">Ask a question to see the response here</div>';
return;
}
const responseContainer = this.shadowRoot.getElementById('responseContainer'); const responseContainer = this.shadowRoot.getElementById('responseContainer');
if (!responseContainer) return; if (!responseContainer) return;
// ✨ 로딩 상태를 먼저 확인
if (this.isLoading) { if (this.isLoading) {
responseContainer.innerHTML = ` responseContainer.innerHTML = `<div class="loading-dots">...</div>`;
<div class="loading-dots">
<div class="loading-dot"></div><div class="loading-dot"></div><div class="loading-dot"></div>
</div>`;
return; return;
} }
let textToRender = this.isStreaming ? this.accumulatedResponse : this.currentResponse; // ✨ 응답이 없을 때의 처리
if (!this.currentResponse) {
// 불완전한 마크다운 수정 responseContainer.innerHTML = `<div class="empty-state">...</div>`;
textToRender = this.fixIncompleteMarkdown(textToRender); return;
}
let textToRender = this.fixIncompleteMarkdown(this.currentResponse);
textToRender = this.fixIncompleteCodeBlocks(textToRender); textToRender = this.fixIncompleteCodeBlocks(textToRender);
if (this.isLibrariesLoaded && this.marked && this.DOMPurify) { if (this.isLibrariesLoaded && this.marked && this.DOMPurify) {
try { try {
@ -1136,27 +1068,10 @@ export class AskView extends LitElement {
this.adjustWindowHeightThrottled(); this.adjustWindowHeightThrottled();
} }
clearResponseContent() {
this.currentResponse = '';
this.currentQuestion = '';
this.isLoading = false;
this.isStreaming = false;
this.headerText = 'AI Response';
this.showTextInput = true;
this.accumulatedResponse = '';
this.requestUpdate();
this.renderContent(); // 👈 updateResponseContent() 대신 renderContent() 호출
}
handleToggleTextInput() {
this.showTextInput = !this.showTextInput;
this.requestUpdate();
}
requestWindowResize(targetHeight) { requestWindowResize(targetHeight) {
if (window.require) { if (window.api) {
const { ipcRenderer } = window.require('electron'); window.api.askView.adjustWindowHeight(targetHeight);
ipcRenderer.invoke('adjust-window-height', targetHeight);
} }
} }
@ -1196,13 +1111,6 @@ export class AskView extends LitElement {
.replace(/`(.*?)`/g, '<code>$1</code>'); .replace(/`(.*?)`/g, '<code>$1</code>');
} }
closeResponsePanel() {
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('force-close-window', 'ask');
}
}
fixIncompleteMarkdown(text) { fixIncompleteMarkdown(text) {
if (!text) return text; if (!text) return text;
@ -1240,29 +1148,6 @@ export class AskView extends LitElement {
return text; return text;
} }
// ✨ processAssistantQuestion 수정
async processAssistantQuestion(question) {
this.currentQuestion = question;
this.showTextInput = false;
this.isLoading = true;
this.isStreaming = false;
this.currentResponse = '';
this.accumulatedResponse = '';
this.startHeaderAnimation();
this.requestUpdate();
this.renderContent();
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('ask:sendMessage', question).catch(error => {
console.error('Error processing assistant question:', error);
this.isLoading = false;
this.isStreaming = false;
this.currentResponse = `Error: ${error.message}`;
this.renderContent();
});
}
}
async handleCopy() { async handleCopy() {
if (this.copyState === 'copied') return; if (this.copyState === 'copied') return;
@ -1332,33 +1217,16 @@ export class AskView extends LitElement {
} }
} }
async handleSendText() { async handleSendText(e, overridingText = '') {
const textInput = this.shadowRoot?.getElementById('textInput'); const textInput = this.shadowRoot?.getElementById('textInput');
if (!textInput) return; const text = (overridingText || textInput?.value || '').trim();
const text = textInput.value.trim();
if (!text) return; if (!text) return;
textInput.value = ''; textInput.value = '';
this.currentQuestion = text; if (window.api) {
this.lineCopyState = {}; window.api.askView.sendMessage(text).catch(error => {
this.showTextInput = false;
this.isLoading = true;
this.isStreaming = false;
this.currentResponse = '';
this.accumulatedResponse = '';
this.startHeaderAnimation();
this.requestUpdate();
this.renderContent();
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('ask:sendMessage', text).catch(error => {
console.error('Error sending text:', error); console.error('Error sending text:', error);
this.isLoading = false;
this.isStreaming = false;
this.currentResponse = `Error: ${error.message}`;
this.renderContent();
}); });
} }
} }
@ -1380,50 +1248,25 @@ export class AskView extends LitElement {
updated(changedProperties) { updated(changedProperties) {
super.updated(changedProperties); super.updated(changedProperties);
if (changedProperties.has('isLoading')) {
// ✨ isLoading 또는 currentResponse가 변경될 때마다 뷰를 다시 그립니다.
if (changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
this.renderContent(); this.renderContent();
} }
if (changedProperties.has('showTextInput') || changedProperties.has('isLoading')) { if (changedProperties.has('showTextInput') || changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
this.adjustWindowHeightThrottled(); this.adjustWindowHeightThrottled();
} }
if (changedProperties.has('showTextInput') && this.showTextInput) { if (changedProperties.has('showTextInput') && this.showTextInput) {
this.focusTextInput(); this.focusTextInput();
} }
} }
focusTextInput() {
requestAnimationFrame(() => {
const textInput = this.shadowRoot?.getElementById('textInput');
if (textInput) {
textInput.focus();
}
});
}
firstUpdated() { firstUpdated() {
setTimeout(() => this.adjustWindowHeight(), 200); setTimeout(() => this.adjustWindowHeight(), 200);
} }
handleGlobalSendRequest() {
const textInput = this.shadowRoot?.getElementById('textInput');
if (!this.showTextInput) {
this.showTextInput = true;
this.requestUpdate();
this.focusTextInput();
return;
}
if (!textInput) return;
textInput.focus();
if (!textInput.value.trim()) return;
this.handleSendText();
}
getTruncatedQuestion(question, maxLength = 30) { getTruncatedQuestion(question, maxLength = 30) {
if (!question) return ''; if (!question) return '';
@ -1431,27 +1274,11 @@ export class AskView extends LitElement {
return question.substring(0, maxLength) + '...'; return question.substring(0, maxLength) + '...';
} }
handleInputFocus() {
this.isInputFocused = true;
}
handleInputBlur(e) {
this.isInputFocused = false;
// 잠시 후 포커스가 다른 곳으로 갔는지 확인
setTimeout(() => {
const activeElement = this.shadowRoot?.activeElement || document.activeElement;
const textInput = this.shadowRoot?.getElementById('textInput');
// 포커스가 AskView 내부가 아니고, 응답이 없는 경우
if (!this.currentResponse && !this.isLoading && !this.isStreaming && activeElement !== textInput && !this.isInputFocused) {
this.closeIfNoContent();
}
}, 200);
}
render() { render() {
const hasResponse = this.isLoading || this.currentResponse || this.isStreaming; const hasResponse = this.isLoading || this.currentResponse || this.isStreaming;
const headerText = this.isLoading ? 'Thinking...' : 'AI Response';
return html` return html`
<div class="ask-container"> <div class="ask-container">
@ -1464,7 +1291,7 @@ export class AskView extends LitElement {
<path d="M8 12l2 2 4-4" /> <path d="M8 12l2 2 4-4" />
</svg> </svg>
</div> </div>
<span class="response-label ${this.headerAnimating ? 'animating' : ''}">${this.headerText}</span> <span class="response-label">${headerText}</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<span class="question-text">${this.getTruncatedQuestion(this.currentQuestion)}</span> <span class="question-text">${this.getTruncatedQuestion(this.currentQuestion)}</span>
@ -1486,7 +1313,7 @@ export class AskView extends LitElement {
<path d="M20 6L9 17l-5-5" /> <path d="M20 6L9 17l-5-5" />
</svg> </svg>
</button> </button>
<button class="close-button" @click=${this.closeResponsePanel}> <button class="close-button" @click=${this.handleCloseAskWindow}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18" /> <line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" /> <line x1="6" y1="6" x2="18" y2="18" />
@ -1509,7 +1336,6 @@ export class AskView extends LitElement {
placeholder="Ask about your screen or audio" placeholder="Ask about your screen or audio"
@keydown=${this.handleTextKeydown} @keydown=${this.handleTextKeydown}
@focus=${this.handleInputFocus} @focus=${this.handleInputFocus}
@blur=${this.handleInputBlur}
/> />
<button <button
class="submit-btn" class="submit-btn"
@ -1527,7 +1353,7 @@ export class AskView extends LitElement {
// Dynamically resize the BrowserWindow to fit current content // Dynamically resize the BrowserWindow to fit current content
adjustWindowHeight() { adjustWindowHeight() {
if (!window.require) return; if (!window.api) return;
this.updateComplete.then(() => { this.updateComplete.then(() => {
const headerEl = this.shadowRoot.querySelector('.response-header'); const headerEl = this.shadowRoot.querySelector('.response-header');
@ -1544,8 +1370,7 @@ export class AskView extends LitElement {
const targetHeight = Math.min(700, idealHeight); const targetHeight = Math.min(700, idealHeight);
const { ipcRenderer } = window.require('electron'); window.api.askView.adjustWindowHeight(targetHeight);
ipcRenderer.invoke('adjust-window-height', targetHeight);
}).catch(err => console.error('AskView adjustWindowHeight error:', err)); }).catch(err => console.error('AskView adjustWindowHeight error:', err));
} }

View File

Before

Width:  |  Height:  |  Size: 877 B

After

Width:  |  Height:  |  Size: 877 B

View File

Before

Width:  |  Height:  |  Size: 226 B

After

Width:  |  Height:  |  Size: 226 B

View File

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 125 KiB

After

Width:  |  Height:  |  Size: 125 KiB

View File

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 68 KiB

View File

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Some files were not shown because too many files have changed in this diff Show More