Compare commits

..

30 Commits

Author SHA1 Message Date
sanio
7455907835 fix auto adujusting height 2025-07-15 21:43:46 +09:00
samtiz
60a8c30157 Merge branch 'main' into feature/encryption 2025-07-15 20:16:48 +09:00
samtiz
dad74875a0 keychain permission + modelStateService rely only on db 2025-07-15 20:16:38 +09:00
sanio
bba38ac56f refactor windowmanager finished 2025-07-15 20:10:46 +09:00
sanio
ecae4050bb refactored layoutmanager, movementmanager 2025-07-15 18:21:22 +09:00
sanio
f755fdb9e3 cleaning dependency in windowmanager 2025-07-15 18:00:31 +09:00
samtiz
a27ab05fa8 Merge branch 'refactor/localmodel' into feature/encryption 2025-07-15 16:29:16 +09:00
samtiz
8592d1c4ed remove providerSettings firebaseRepository + minor refactor 2025-07-15 16:10:32 +09:00
samtiz
ab23c10006 WIP encryption cleanup + providerSetting refactor 2025-07-15 16:01:34 +09:00
jhyang0
fc16532cd9 whisper install url fix 2025-07-15 15:48:45 +09:00
jhyang0
7f98acb5e3 whisper install fix 2025-07-15 15:32:24 +09:00
sanio
698473007a delete movementmanager dependency in shortcutsservice 2025-07-15 14:59:14 +09:00
jhyang0
9359b32c01 Add localAIManager 2025-07-15 14:05:50 +09:00
jhyang0
6ece74737b Refactor: Implement local AI service management system
- Add LocalAIServiceManager for centralized local AI service lifecycle management
- Refactor provider settings to support local AI service configuration
- Remove userModelSelections in favor of provider settings integration
- Update whisper service to use new local AI management system
- Implement lazy loading and auto-cleanup for local AI services
- Update UI components to reflect new local AI service architecture
2025-07-15 14:04:34 +09:00
sanio
c0cf74273a add deepgram 2025-07-15 03:47:47 +09:00
sanio
4d93df09e2 centralized window layout/movement feature to windowmanager 2025-07-15 01:01:17 +09:00
samtiz
94ae002d83 fix: remove authservice injection on userRepo 2025-07-14 04:29:12 +09:00
samtiz
a2f57cbfa9 authService injection on init 2025-07-14 04:11:38 +09:00
jhyang0
e244ce1d4d add smd 2025-07-14 03:23:53 +09:00
sanio
f764ad5362 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-14 03:16:37 +09:00
sanio
bcc8a59882 add screen only ask, retrieve loading dot 2025-07-14 03:16:29 +09:00
samtiz
c464098951 icon path fix 2025-07-14 03:10:49 +09:00
samtiz
2a3c7db200 header privacy button fix 2025-07-14 03:00:28 +09:00
sanio
aa14a1d0b6 fix askview focus logic 2025-07-14 02:47:37 +09:00
sanio
fbe5c22aa4 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-14 02:24:03 +09:00
sanio
a509e87b22 add fade animation to window 2025-07-14 02:24:00 +09:00
jhyang0
290ee0ed29 minor update + merge 2025-07-14 02:13:14 +09:00
sanio
2bfcadecb4 delete legacy code 2025-07-14 01:53:26 +09:00
sanio
8da13dcb27 fix window animation 2025-07-14 00:23:46 +09:00
Ho Jin Yu
7d33ea9ca8
[Refactor] full refactor and file structure changed (#125)
* refactoring the bridge

* Update aec submodule

* folder structure refactor

* fixing ask logic

* resolve import err

* fix askview

* fix header content html path

* fix systemaudiodump path

* centralized ask logic

* delete legacy code

* change askservice to class

* settingsService facade

* fix getCurrentModelInfo

* common service ipc moved to featureBridge

* featureBridge init

* ui fix

* add featureBridge func for listenservice

* fix preload conflict

* shortcuts seperated

* refactor ask

* transfer roles from askview to askservice

* modifying windowBridge

* delete legacy ask code

* retrieve conversation history for askserice

* fix legacy code

* shortcut moved

* change naming for featurebridge

* screenshot moved from windowManager

* rough refactor done

---------

Co-authored-by: sanio <sanio@pickle.com>
Co-authored-by: jhyang0 <junhyuck0819@gmail.com>
2025-07-13 15:31:24 +09:00
67 changed files with 10598 additions and 6906 deletions

View File

@ -1,2 +1,2 @@
src/assets
src/ui/assets
node_modules

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

@ -39,7 +39,7 @@ asarUnpack:
# Windows configuration
win:
icon: src/assets/logo.ico
icon: src/ui/assets/logo.ico
target:
- target: nsis
arch: x64
@ -67,7 +67,7 @@ mac:
# The application category type
category: public.app-category.utilities
# Path to the .icns icon file
icon: src/assets/logo.icns
icon: src/ui/assets/logo.icns
# Minimum macOS version (supports both Intel and Apple Silicon)
minimumSystemVersion: '11.0'
hardenedRuntime: true

78
package-lock.json generated
View File

@ -11,6 +11,7 @@
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
"@deepgram/sdk": "^4.9.1",
"@google/genai": "^1.8.0",
"@google/generative-ai": "^0.24.1",
"axios": "^1.10.0",
@ -54,6 +55,50 @@
"anthropic-ai-sdk": "bin/cli"
}
},
"node_modules/@deepgram/captions": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@deepgram/captions/-/captions-1.2.0.tgz",
"integrity": "sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.10"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@deepgram/sdk": {
"version": "4.9.1",
"resolved": "https://registry.npmjs.org/@deepgram/sdk/-/sdk-4.9.1.tgz",
"integrity": "sha512-a30Sed6OIRldnW1U0Q0Orvhjojq4O/1pMv6ijj+3j8735LBBfAJvlJpRCjrgtzBpnkKlY6v3bV5F8qUUSpz2yg==",
"license": "MIT",
"dependencies": {
"@deepgram/captions": "^1.1.1",
"@types/node": "^18.19.39",
"cross-fetch": "^3.1.5",
"deepmerge": "^4.3.1",
"events": "^3.3.0",
"ws": "^8.17.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@deepgram/sdk/node_modules/@types/node": {
"version": "18.19.118",
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.118.tgz",
"integrity": "sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug==",
"license": "MIT",
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@deepgram/sdk/node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"license": "MIT"
},
"node_modules/@develar/schema-utils": {
"version": "2.6.5",
"dev": true,
@ -2992,6 +3037,15 @@
"optional": true,
"peer": true
},
"node_modules/cross-fetch": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
"license": "MIT",
"dependencies": {
"node-fetch": "^2.7.0"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -3020,6 +3074,12 @@
"node": ">=6"
}
},
"node_modules/dayjs": {
"version": "1.11.13",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
"license": "MIT"
},
"node_modules/debounce-fn": {
"version": "4.0.0",
"license": "MIT",
@ -3078,6 +3138,15 @@
"node": ">=4.0.0"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/defaults": {
"version": "1.0.4",
"dev": true,
@ -3735,6 +3804,15 @@
"node": ">=6"
}
},
"node_modules/events": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
"license": "MIT",
"engines": {
"node": ">=0.8.x"
}
},
"node_modules/expand-template": {
"version": "2.0.3",
"license": "(MIT OR WTFPL)",

View File

@ -33,6 +33,7 @@
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
"@deepgram/sdk": "^4.9.1",
"@google/genai": "^1.8.0",
"@google/generative-ai": "^0.24.1",
"axios": "^1.10.0",

View File

@ -1,73 +1,235 @@
// src/bridge/featureBridge.js
const { ipcMain } = require('electron');
const { ipcMain, app, BrowserWindow } = require('electron');
const settingsService = require('../features/settings/settingsService');
const authService = require('../features/common/services/authService');
const whisperService = require('../features/common/services/whisperService');
const ollamaService = require('../features/common/services/ollamaService');
const modelStateService = require('../features/common/services/modelStateService');
const shortcutsService = require('../features/shortcuts/shortcutsService');
const presetRepository = require('../features/common/repositories/preset');
const localAIManager = require('../features/common/services/localAIManager');
const askService = require('../features/ask/askService');
const listenService = require('../features/listen/listenService');
const permissionService = require('../features/common/services/permissionService');
const encryptionService = require('../features/common/services/encryptionService');
module.exports = {
// Renderer로부터의 요청을 수신
// Renderer로부터의 요청을 수신하고 서비스로 전달
initialize() {
// Ask 관련 핸들러 추가
ipcMain.handle('ask:sendMessage', async (event, userPrompt) => {
return askService.sendMessage(userPrompt);
// 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: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('settings:getCurrentShortcuts', async () => await shortcutsService.loadKeybinds());
ipcMain.handle('shortcut:getDefaultShortcuts', async () => await shortcutsService.handleRestoreDefaults());
ipcMain.handle('shortcut:closeShortcutSettingsWindow', async () => await shortcutsService.closeShortcutSettingsWindow());
ipcMain.handle('shortcut:openShortcutSettingsWindow', async () => await shortcutsService.openShortcutSettingsWindow());
ipcMain.handle('shortcut:saveShortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
ipcMain.handle('shortcut:toggleAllWindowsVisibility', async () => await shortcutsService.toggleAllWindowsVisibility());
// 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-keychain-completed', async () => await permissionService.markKeychainCompleted());
ipcMain.handle('check-keychain-completed', async () => await permissionService.checkKeychainCompleted());
ipcMain.handle('initialize-encryption-key', async () => {
const userId = authService.getCurrentUserId();
await encryptionService.initializeKey(userId);
return { success: true };
});
// 기존 ask 핸들러 유지
ipcMain.handle('feature:ask', (e, query) => {
// 실제로는 여기서 Controller -> Service 로직 수행
return `"${query}"에 대한 답변입니다.`;
// 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());
ipcMain.handle('ask:closeAskWindow', async () => await askService.closeAskWindow());
// 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:isSessionActive', async () => await listenService.isSessionActive());
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 };
}
});
// settings 관련 핸들러 추가
ipcMain.handle('settings:getSettings', async () => {
return await settingsService.getSettings();
// ModelStateService
ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
ipcMain.handle('model:get-all-keys', async () => await 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', async () => await modelStateService.getSelectedModels());
ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', async (e, { type }) => await modelStateService.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', async () => await modelStateService.areProvidersConfigured());
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
ipcMain.handle('model:re-initialize-state', async () => await modelStateService.initialize());
// LocalAIManager 이벤트를 모든 윈도우에 브로드캐스트
localAIManager.on('install-progress', (service, data) => {
const event = { service, ...data };
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:install-progress', event);
}
});
});
ipcMain.handle('settings:saveSettings', async (event, settings) => {
return await settingsService.saveSettings(settings);
localAIManager.on('installation-complete', (service) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:installation-complete', { service });
}
});
});
ipcMain.handle('settings:getPresets', async () => {
return await settingsService.getPresets();
localAIManager.on('error', (error) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:error-occurred', error);
}
});
});
ipcMain.handle('settings:getPresetTemplates', async () => {
return await settingsService.getPresetTemplates();
// Handle error-occurred events from LocalAIManager's error handling
localAIManager.on('error-occurred', (error) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:error-occurred', error);
}
});
});
ipcMain.handle('settings:createPreset', async (event, title, prompt) => {
return await settingsService.createPreset(title, prompt);
localAIManager.on('model-ready', (data) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:model-ready', data);
}
});
});
ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => {
return await settingsService.updatePreset(id, title, prompt);
});
ipcMain.handle('settings:deletePreset', async (event, id) => {
return await settingsService.deletePreset(id);
});
ipcMain.handle('settings:saveApiKey', async (event, apiKey, provider) => {
return await settingsService.saveApiKey(apiKey, provider);
});
ipcMain.handle('settings:removeApiKey', async () => {
return await settingsService.removeApiKey();
});
ipcMain.handle('settings:updateContentProtection', async (event, enabled) => {
return await settingsService.updateContentProtection(enabled);
localAIManager.on('state-changed', (service, state) => {
const event = { service, ...state };
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:service-status-changed', event);
}
});
});
ipcMain.handle('settings:get-auto-update', async () => {
return await settingsService.getAutoUpdateSetting();
// 주기적 상태 동기화 시작
localAIManager.startPeriodicSync();
// ModelStateService 이벤트를 모든 윈도우에 브로드캐스트
modelStateService.on('state-updated', (state) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('model-state:updated', state);
}
});
});
modelStateService.on('settings-updated', () => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('settings-updated');
}
});
});
modelStateService.on('force-show-apikey-header', () => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('force-show-apikey-header');
}
});
});
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => {
console.log('[SettingsService] Setting auto update setting:', isEnabled);
return await settingsService.setAutoUpdateSetting(isEnabled);
// LocalAI 통합 핸들러 추가
ipcMain.handle('localai:install', async (event, { service, options }) => {
return await localAIManager.installService(service, options);
});
ipcMain.handle('localai:get-status', async (event, service) => {
return await localAIManager.getServiceStatus(service);
});
ipcMain.handle('localai:start-service', async (event, service) => {
return await localAIManager.startService(service);
});
ipcMain.handle('localai:stop-service', async (event, service) => {
return await localAIManager.stopService(service);
});
ipcMain.handle('localai:install-model', async (event, { service, modelId, options }) => {
return await localAIManager.installModel(service, modelId, options);
});
ipcMain.handle('localai:get-installed-models', async (event, service) => {
return await localAIManager.getInstalledModels(service);
});
ipcMain.handle('localai:run-diagnostics', async (event, service) => {
return await localAIManager.runDiagnostics(service);
});
ipcMain.handle('localai:repair-service', async (event, service) => {
return await localAIManager.repairService(service);
});
console.log('[FeatureBridge] Initialized with ask and settings handlers.');
// 에러 처리 핸들러
ipcMain.handle('localai:handle-error', async (event, { service, errorType, details }) => {
return await localAIManager.handleError(service, errorType, details);
});
// 전체 상태 조회
ipcMain.handle('localai:get-all-states', async (event) => {
return await localAIManager.getAllServiceStates();
});
console.log('[FeatureBridge] Initialized with all feature handlers.');
},
// Renderer로 상태를 전송

View File

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

View File

@ -1,74 +1,34 @@
// src/bridge/windowBridge.js
const { ipcMain, BrowserWindow } = require('electron');
const { windowPool, settingsHideTimer, app, shell } = require('../window/windowManager'); // 필요 변수 require
const { ipcMain, shell } = require('electron');
// Bridge는 단순히 IPC 핸들러를 등록하는 역할만 함 (비즈니스 로직 없음)
module.exports = {
// Renderer로부터의 요청을 수신
initialize() {
// 기존
ipcMain.on('window:hide', (e) => BrowserWindow.fromWebContents(e.sender)?.hide());
// initialize 시점에 windowManager를 require하여 circular dependency 문제 해결
const windowManager = require('../window/windowManager');
// 기존 IPC 핸들러들
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.on('show-settings-window', () => windowManager.showSettingsWindow());
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
// windowManager 관련 추가
ipcMain.handle('toggle-content-protection', () => {
// windowManager의 toggle-content-protection 로직
isContentProtectionOn = !isContentProtectionOn;
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.setContentProtection(isContentProtectionOn);
}
});
return isContentProtectionOn;
});
ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
ipcMain.handle('open-external', (event, url) => shell.openExternal(url));
ipcMain.handle('get-content-protection-status', () => {
return isContentProtectionOn;
});
ipcMain.handle('open-shortcut-editor', () => {
// open-shortcut-editor 로직 (windowPool 등 필요시 require)
const header = windowPool.get('header');
if (!header) return;
globalShortcut.unregisterAll();
createFeatureWindows(header, 'shortcut-settings');
});
// 다른 관련 핸들러 추가 (quit-application, etc.)
ipcMain.handle('quit-application', () => {
app.quit();
});
// 추가: show-settings-window
ipcMain.on('show-settings-window', (event, bounds) => {
if (!bounds) return;
const win = windowPool.get('settings');
if (win && !win.isDestroyed()) {
if (settingsHideTimer) clearTimeout(settingsHideTimer);
// 위치 조정 로직 (기존 복사)
const header = windowPool.get('header');
const headerBounds = header?.getBounds() ?? { x: 0, y: 0 };
const settingsBounds = win.getBounds();
const disp = getCurrentDisplay(header);
const { x: waX, y: waY, width: waW, height: waH } = disp.workArea;
let x = Math.round(headerBounds.x + (bounds?.x ?? 0) + (bounds?.width ?? 0) / 2 - settingsBounds.width / 2);
let y = Math.round(headerBounds.y + (bounds?.y ?? 0) + (bounds?.height ?? 0) + 31);
x = Math.max(waX + 10, Math.min(waX + waW - settingsBounds.width - 10, x));
y = Math.max(waY + 10, Math.min(waY + waH - settingsBounds.height - 10, y));
win.setBounds({ x, y });
win.__lockedByButton = true;
win.show();
win.moveTop();
win.setAlwaysOnTop(true);
}
});
// 추가: hide-settings-window 등 다른 핸들러 복사
// ... (hide-settings-window, cancel-hide-settings-window, quit-application, open-login-page, firebase-logout, move-window-step 등)
// 예: ipcMain.handle('open-login-page', () => { shell.openExternal(...); });
// 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-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
ipcMain.handle('adjust-window-height', (event, { winName, height }) => windowManager.adjustWindowHeight(winName, height));
},
// Renderer로 상태를 전송
notifyFocusChange(win, isFocused) {
win.webContents.send('window:focus-change', isFocused);
},
}
};

View File

@ -1,144 +1,450 @@
const { ipcMain, BrowserWindow } = require('electron');
const { BrowserWindow } = require('electron');
const { createStreamingLLM } = require('../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../window/windowManager');
const authService = require('../common/services/authService');
// Lazy require helper to avoid circular dependency issues
const getWindowManager = () => require('../../window/windowManager');
const internalBridge = require('../../bridge/internalBridge');
const getWindowPool = () => {
try {
return getWindowManager().windowPool;
} catch {
return null;
}
};
const sessionRepository = require('../common/repositories/session');
const askRepository = require('./repositories');
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) {
if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.';
return conversationTexts.slice(-30).join('\n');
// Try to load sharp, but don't fail if it's not available
let sharp;
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
function getConversationHistory() {
const listenService = global.listenService;
return listenService ? listenService.getConversationHistory() : [];
}
async function captureScreenshot(options = {}) {
if (process.platform === 'darwin') {
try {
const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`);
async function sendMessage(userPrompt) {
if (!userPrompt || userPrompt.trim().length === 0) {
console.warn('[AskService] Cannot process empty message');
return { success: false, error: 'Empty message' };
await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]);
const imageBuffer = await fs.promises.readFile(tempPath);
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 };
}
}
let sessionId;
try {
console.log(`[AskService] Processing message: ${userPrompt.substring(0, 50)}...`);
// --- Save user's message immediately ---
// This ensures the user message is always timestamped before the assistant's response.
sessionId = await sessionRepository.getOrCreateActive('ask');
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()}` },
],
const sources = await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: {
width: 1920,
height: 1080,
},
];
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 ---
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = '';
return {
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,
};
}
}
const askWin = windowPool.get('ask');
if (!askWin || askWin.isDestroyed()) {
console.error("[AskService] Ask window is not available to send stream to.");
reader.cancel();
/**
* @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(inputScreenOnly = false) {
const askWindow = getWindowPool()?.get('ask');
let shouldSendScreenOnly = false;
if (inputScreenOnly && this.state.showTextInput && askWindow && askWindow.isVisible()) {
shouldSendScreenOnly = true;
await this.sendMessage('', []);
return;
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
const hasContent = this.state.isLoading || this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim() !== '');
if (askWindow && askWindow.isVisible() && hasContent) {
this.state.showTextInput = !this.state.showTextInput;
this._broadcastState();
} else {
if (askWindow && askWindow.isVisible()) {
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
this.state.isVisible = false;
} else {
console.log('[AskService] Showing hidden Ask window');
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
this.state.isVisible = true;
}
if (this.state.isVisible) {
this.state.showTextInput = true;
this._broadcastState();
}
}
}
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6);
if (data === '[DONE]') {
askWin.webContents.send('ask-response-stream-end');
// Save assistant's message to DB
try {
// sessionId is already available from when we saved the user prompt
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`);
} catch(dbError) {
console.error("[AskService] DB: Failed to save assistant response:", dbError);
async closeAskWindow () {
if (this.abortController) {
this.abortController.abort('Window closed by user');
this.abortController = null;
}
this.state = {
isVisible : false,
isLoading : false,
isStreaming : false,
currentQuestion: '',
currentResponse: '',
showTextInput : true,
};
this._broadcastState();
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
return { success: true };
}
/**
*
* @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=[]) {
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
this.state = {
...this.state,
isLoading: true,
isStreaming: false,
currentQuestion: userPrompt,
currentResponse: '',
showTextInput: false,
};
this._broadcastState();
if (this.abortController) {
this.abortController.abort('New request received.');
}
this.abortController = new AbortController();
const { signal } = this.abortController;
let sessionId;
try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
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 = await 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()}`
}
return { success: true, response: fullResponse };
];
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.' };
}
try {
const json = JSON.parse(data);
const token = json.choices[0]?.delta?.content || '';
if (token) {
fullResponse += token;
askWin.webContents.send('ask-response-chunk', { token });
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();
let fullResponse = '';
try {
this.state.isLoading = false;
this.state.isStreaming = true;
this._broadcastState();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6);
if (data === '[DONE]') {
return;
}
try {
const json = JSON.parse(data);
const token = json.choices[0]?.delta?.content || '';
if (token) {
fullResponse += token;
this.state.currentResponse = fullResponse;
this._broadcastState();
}
} catch (error) {
}
} 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() {
// IPC 핸들러는 featureBridge.js로 이동됨
console.log('[AskService] Initialized and ready.');
}
const askService = new AskService();
module.exports = {
initialize,
sendMessage, // sendMessage 함수 export 추가
};
module.exports = askService;

View File

@ -57,6 +57,14 @@ const PROVIDERS = {
],
sttModels: [],
},
'deepgram': {
name: 'Deepgram',
handler: () => require("./providers/deepgram"),
llmModels: [],
sttModels: [
{ id: 'nova-3', name: 'Nova-3 (General)' },
],
},
'ollama': {
name: 'Ollama (Local)',
handler: () => require("./providers/ollama"),
@ -68,7 +76,8 @@ const PROVIDERS = {
handler: () => {
// This needs to remain a function due to its conditional logic for renderer/main process
if (typeof window === 'undefined') {
return require("./providers/whisper");
const { WhisperProvider } = require("./providers/whisper");
return new WhisperProvider();
}
// Return a dummy object for the renderer process
return {
@ -147,6 +156,7 @@ function getProviderClass(providerId) {
'openai': 'OpenAIProvider',
'anthropic': 'AnthropicProvider',
'gemini': 'GeminiProvider',
'deepgram': 'DeepgramProvider',
'ollama': 'OllamaProvider',
'whisper': 'WhisperProvider'
};

View File

@ -0,0 +1,111 @@
// providers/deepgram.js
const { createClient, LiveTranscriptionEvents } = require('@deepgram/sdk');
const WebSocket = require('ws');
/**
* Deepgram Provider 클래스. API 유효성 검사를 담당합니다.
*/
class DeepgramProvider {
/**
* Deepgram API 키의 유효성을 검사합니다.
* @param {string} key - 검사할 Deepgram API
* @returns {Promise<{success: boolean, error?: string}>}
*/
static async validateApiKey(key) {
if (!key || typeof key !== 'string') {
return { success: false, error: 'Invalid Deepgram API key format.' };
}
try {
// ✨ 변경점: SDK 대신 직접 fetch로 API를 호출하여 안정성 확보 (openai.js 방식)
const response = await fetch('https://api.deepgram.com/v1/projects', {
headers: { 'Authorization': `Token ${key}` }
});
if (response.ok) {
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
const message = errorData.err_msg || `Validation failed with status: ${response.status}`;
return { success: false, error: message };
}
} catch (error) {
console.error(`[DeepgramProvider] Network error during key validation:`, error);
return { success: false, error: error.message || 'A network error occurred during validation.' };
}
}
}
function createSTT({
apiKey,
language = 'en-US',
sampleRate = 24000,
callbacks = {},
}) {
const qs = new URLSearchParams({
model: 'nova-3',
encoding: 'linear16',
sample_rate: sampleRate.toString(),
language,
smart_format: 'true',
interim_results: 'true',
channels: '1',
});
const url = `wss://api.deepgram.com/v1/listen?${qs}`;
const ws = new WebSocket(url, {
headers: { Authorization: `Token ${apiKey}` },
});
ws.binaryType = 'arraybuffer';
return new Promise((resolve, reject) => {
const to = setTimeout(() => {
ws.terminate();
reject(new Error('DG open timeout (10s)'));
}, 10_000);
ws.on('open', () => {
clearTimeout(to);
resolve({
sendRealtimeInput: (buf) => ws.send(buf),
close: () => ws.close(1000, 'client'),
});
});
ws.on('message', raw => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.channel?.alternatives?.[0]?.transcript !== undefined) {
callbacks.onmessage?.({ provider: 'deepgram', ...msg });
}
});
ws.on('close', (code, reason) =>
callbacks.onclose?.({ code, reason: reason.toString() })
);
ws.on('error', err => {
clearTimeout(to);
callbacks.onerror?.(err);
reject(err);
});
});
}
// ... (LLM 관련 Placeholder 함수들은 그대로 유지) ...
function createLLM(opts) {
console.warn("[Deepgram] LLM not supported.");
return { generateContent: async () => { throw new Error("Deepgram does not support LLM functionality."); } };
}
function createStreamingLLM(opts) {
console.warn("[Deepgram] Streaming LLM not supported.");
return { streamChat: async () => { throw new Error("Deepgram does not support Streaming LLM functionality."); } };
}
module.exports = {
DeepgramProvider,
createSTT,
createLLM,
createStreamingLLM
};

View File

@ -1,6 +1,79 @@
const http = require('http');
const fetch = require('node-fetch');
// Request Queue System for Ollama API (only for non-streaming requests)
class RequestQueue {
constructor() {
this.queue = [];
this.processing = false;
this.streamingActive = false;
}
async addStreamingRequest(requestFn) {
// Streaming requests have priority - wait for current processing to finish
while (this.processing) {
await new Promise(resolve => setTimeout(resolve, 50));
}
this.streamingActive = true;
console.log('[Ollama Queue] Starting streaming request (priority)');
try {
const result = await requestFn();
return result;
} finally {
this.streamingActive = false;
console.log('[Ollama Queue] Streaming request completed');
}
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) {
return;
}
// Wait if streaming is active
if (this.streamingActive) {
setTimeout(() => this.process(), 100);
return;
}
this.processing = true;
while (this.queue.length > 0) {
// Check if streaming started while processing queue
if (this.streamingActive) {
this.processing = false;
setTimeout(() => this.process(), 100);
return;
}
const { requestFn, resolve, reject } = this.queue.shift();
try {
console.log(`[Ollama Queue] Processing queued request (${this.queue.length} remaining)`);
const result = await requestFn();
resolve(result);
} catch (error) {
console.error('[Ollama Queue] Request failed:', error);
reject(error);
}
}
this.processing = false;
}
}
// Global request queue instance
const requestQueue = new RequestQueue();
class OllamaProvider {
static async validateApiKey() {
try {
@ -79,71 +152,77 @@ function createLLM({
}
messages.push({ role: 'user', content: userContent.join('\n') });
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
response: {
text: () => result.message.content
},
raw: result
};
} catch (error) {
console.error('Ollama LLM error:', error);
throw error;
}
const result = await response.json();
return {
response: {
text: () => result.message.content
},
raw: result
};
} catch (error) {
console.error('Ollama LLM error:', error);
throw error;
}
});
},
chat: async (messages) => {
const ollamaMessages = convertMessagesToOllamaFormat(messages);
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
content: result.message.content,
raw: result
};
} catch (error) {
console.error('Ollama chat error:', error);
throw error;
}
const result = await response.json();
return {
content: result.message.content,
raw: result
};
} catch (error) {
console.error('Ollama chat error:', error);
throw error;
}
});
}
};
}
@ -165,89 +244,92 @@ function createStreamingLLM({
const ollamaMessages = convertMessagesToOllamaFormat(messages);
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: true,
options: {
temperature,
num_predict: maxTokens,
}
})
});
// Streaming requests have priority over queued requests
return await requestQueue.addStreamingRequest(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: true,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
console.log('[Ollama Provider] Got streaming response');
const stream = new ReadableStream({
async start(controller) {
let buffer = '';
try {
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.message?.content) {
const sseData = JSON.stringify({
choices: [{
delta: {
content: data.message.content
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
}
if (data.done) {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
}
} catch (e) {
console.error('[Ollama Provider] Failed to parse chunk:', e);
}
}
});
response.body.on('end', () => {
controller.close();
console.log('[Ollama Provider] Streaming completed');
});
response.body.on('error', (error) => {
console.error('[Ollama Provider] Streaming error:', error);
controller.error(error);
});
} catch (error) {
console.error('[Ollama Provider] Streaming setup error:', error);
controller.error(error);
}
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
});
return {
ok: true,
body: stream
};
} catch (error) {
console.error('[Ollama Provider] Request error:', error);
throw error;
}
console.log('[Ollama Provider] Got streaming response');
const stream = new ReadableStream({
async start(controller) {
let buffer = '';
try {
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.message?.content) {
const sseData = JSON.stringify({
choices: [{
delta: {
content: data.message.content
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
}
if (data.done) {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
}
} catch (e) {
console.error('[Ollama Provider] Failed to parse chunk:', e);
}
}
});
response.body.on('end', () => {
controller.close();
console.log('[Ollama Provider] Streaming completed');
});
response.body.on('error', (error) => {
console.error('[Ollama Provider] Streaming error:', error);
controller.error(error);
});
} catch (error) {
console.error('[Ollama Provider] Streaming setup error:', error);
controller.error(error);
}
}
});
return {
ok: true,
body: stream
};
} catch (error) {
console.error('[Ollama Provider] Request error:', error);
throw error;
}
});
}
};
}

View File

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

View File

@ -41,7 +41,7 @@ class WhisperSTTSession extends EventEmitter {
startProcessingLoop() {
this.processingInterval = setInterval(async () => {
const minBufferSize = 24000 * 2 * 0.15;
const minBufferSize = 16000 * 2 * 0.15;
if (this.audioBuffer.length >= minBufferSize && !this.process) {
console.log(`[WhisperSTT-${this.sessionId}] Processing audio chunk, buffer size: ${this.audioBuffer.length}`);
await this.processAudioChunk();
@ -184,9 +184,10 @@ class WhisperProvider {
async initialize() {
if (!this.whisperService) {
const { WhisperService } = require('../../services/whisperService');
this.whisperService = new WhisperService();
await this.whisperService.initialize();
this.whisperService = require('../../services/whisperService');
if (!this.whisperService.isInitialized) {
await this.whisperService.initialize();
}
}
}

View File

@ -2,41 +2,49 @@ const DOWNLOAD_CHECKSUMS = {
ollama: {
dmg: {
url: 'https://ollama.com/download/Ollama.dmg',
sha256: null // To be updated with actual checksum
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
exe: {
url: 'https://ollama.com/download/OllamaSetup.exe',
sha256: null // To be updated with actual checksum
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
linux: {
url: 'curl -fsSL https://ollama.com/install.sh | sh',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
}
},
whisper: {
models: {
'whisper-tiny': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-tiny.bin',
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
},
'whisper-base': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-base.bin',
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
},
'whisper-small': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-small.bin',
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
},
'whisper-medium': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-medium.bin',
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208'
}
},
binaries: {
'v1.7.6': {
mac: {
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-mac-x64.zip',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
windows: {
url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
sha256: null // To be updated with actual checksum
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
linux: {
url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
sha256: null // To be updated with actual checksum
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
}
}
}

View File

@ -91,24 +91,28 @@ const LATEST_SCHEMA = {
},
provider_settings: {
columns: [
{ name: 'uid', type: 'TEXT NOT NULL' },
{ name: 'provider', type: 'TEXT NOT NULL' },
{ name: 'api_key', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_model', type: 'TEXT' },
{ name: 'is_active_llm', type: 'INTEGER DEFAULT 0' },
{ name: 'is_active_stt', type: 'INTEGER DEFAULT 0' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'updated_at', type: 'INTEGER' }
],
constraints: ['PRIMARY KEY (uid, provider)']
constraints: ['PRIMARY KEY (provider)']
},
user_model_selections: {
shortcuts: {
columns: [
{ name: 'action', type: 'TEXT PRIMARY KEY' },
{ name: 'accelerator', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' }
]
},
permissions: {
columns: [
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
{ name: 'selected_llm_provider', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_provider', type: 'TEXT' },
{ name: 'selected_stt_model', type: 'TEXT' },
{ name: 'updated_at', type: 'INTEGER' }
{ name: 'keychain_completed', type: 'INTEGER DEFAULT 0' }
]
}
};

View File

@ -33,7 +33,13 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
for (const field of fieldsToEncrypt) {
if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) {
appObject[field] = encryptionService.decrypt(appObject[field]);
try {
appObject[field] = encryptionService.decrypt(appObject[field]);
} catch (error) {
console.warn(`[FirestoreConverter] Failed to decrypt field '${field}' (possibly plaintext or key mismatch):`, error.message);
// Keep the original value instead of failing
// appObject[field] remains as is
}
}
}

View File

@ -6,6 +6,6 @@ function getRepository() {
}
module.exports = {
markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args),
checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...args),
markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args),
checkKeychainCompleted: (...args) => getRepository().checkKeychainCompleted(...args),
};

View File

@ -0,0 +1,18 @@
const sqliteClient = require('../../services/sqliteClient');
function markKeychainCompleted(uid) {
return sqliteClient.query(
'INSERT OR REPLACE INTO permissions (uid, keychain_completed) VALUES (?, 1)',
[uid]
);
}
function checkKeychainCompleted(uid) {
const row = sqliteClient.query('SELECT keychain_completed FROM permissions WHERE uid = ?', [uid]);
return row.length > 0 && row[0].keychain_completed === 1;
}
module.exports = {
markKeychainCompleted,
checkKeychainCompleted
};

View File

@ -1,83 +0,0 @@
const { collection, doc, getDoc, getDocs, setDoc, deleteDoc, query, where } = require('firebase/firestore');
const { getFirestoreInstance: getFirestore } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter');
// Create encrypted converter for provider settings
const providerSettingsConverter = createEncryptedConverter([
'api_key', // Encrypt API keys
'selected_llm_model', // Encrypt model selections for privacy
'selected_stt_model'
]);
function providerSettingsCol() {
const db = getFirestore();
return collection(db, 'provider_settings').withConverter(providerSettingsConverter);
}
async function getByProvider(uid, provider) {
try {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null;
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting provider settings:', error);
return null;
}
}
async function getAllByUid(uid) {
try {
const q = query(providerSettingsCol(), where('uid', '==', uid));
const querySnapshot = await getDocs(q);
return querySnapshot.docs.map(doc => ({ id: doc.id, ...doc.data() }));
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting all provider settings:', error);
return [];
}
}
async function upsert(uid, provider, settings) {
try {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
await setDoc(docRef, settings, { merge: true });
return { changes: 1 };
} catch (error) {
console.error('[ProviderSettings Firebase] Error upserting provider settings:', error);
throw error;
}
}
async function remove(uid, provider) {
try {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
await deleteDoc(docRef);
return { changes: 1 };
} catch (error) {
console.error('[ProviderSettings Firebase] Error removing provider settings:', error);
throw error;
}
}
async function removeAllByUid(uid) {
try {
const settings = await getAllByUid(uid);
const deletePromises = settings.map(setting => {
const docRef = doc(providerSettingsCol(), setting.id);
return deleteDoc(docRef);
});
await Promise.all(deletePromises);
return { changes: settings.length };
} catch (error) {
console.error('[ProviderSettings Firebase] Error removing all provider settings:', error);
throw error;
}
}
module.exports = {
getByProvider,
getAllByUid,
upsert,
remove,
removeAllByUid
};

View File

@ -1,65 +1,68 @@
const firebaseRepository = require('./firebase.repository');
const sqliteRepository = require('./sqlite.repository');
let authService = null;
function setAuthService(service) {
authService = service;
}
function getBaseRepository() {
if (!authService) {
throw new Error('AuthService not set for providerSettings repository');
}
const user = authService.getCurrentUser();
return user.isLoggedIn ? firebaseRepository : sqliteRepository;
// For now, we only have sqlite. This could be expanded later.
return sqliteRepository;
}
const providerSettingsRepositoryAdapter = {
// Core CRUD operations
async getByProvider(provider) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.getByProvider(uid, provider);
return await repo.getByProvider(provider);
},
async getAllByUid() {
async getAll() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.getAllByUid(uid);
return await repo.getAll();
},
async upsert(provider, settings) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
const now = Date.now();
const settingsWithMeta = {
...settings,
uid,
provider,
updated_at: now,
created_at: settings.created_at || now
};
return await repo.upsert(uid, provider, settingsWithMeta);
return await repo.upsert(provider, settingsWithMeta);
},
async remove(provider) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.remove(uid, provider);
return await repo.remove(provider);
},
async removeAllByUid() {
async removeAll() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.removeAllByUid(uid);
return await repo.removeAll();
},
async getRawApiKeys() {
// This function should always target the local sqlite DB,
// as it's part of the local-first boot sequence.
return await sqliteRepository.getRawApiKeys();
},
async getActiveProvider(type) {
const repo = getBaseRepository();
return await repo.getActiveProvider(type);
},
async setActiveProvider(provider, type) {
const repo = getBaseRepository();
return await repo.setActiveProvider(provider, type);
},
async getActiveSettings() {
const repo = getBaseRepository();
return await repo.getActiveSettings();
}
};
module.exports = {
...providerSettingsRepositoryAdapter,
setAuthService
...providerSettingsRepositoryAdapter
};

View File

@ -1,37 +1,59 @@
const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(uid, provider) {
function getByProvider(provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
return stmt.get(uid, provider) || null;
const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?');
const result = stmt.get(provider) || null;
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
return result;
}
function getAllByUid(uid) {
function getAll() {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
return stmt.all(uid);
const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider');
const results = stmt.all();
return results.map(result => {
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
return result;
});
}
function upsert(uid, provider, settings) {
function upsert(provider, settings) {
// Validate: prevent direct setting of active status
if (settings.is_active_llm || settings.is_active_stt) {
console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.');
}
const db = sqliteClient.getDb();
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(`
INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(uid, provider) DO UPDATE SET
INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider) DO UPDATE SET
api_key = excluded.api_key,
selected_llm_model = excluded.selected_llm_model,
selected_stt_model = excluded.selected_stt_model,
-- is_active_llm and is_active_stt are NOT updated here
-- Use setActiveProvider() to change active status
updated_at = excluded.updated_at
`);
const result = stmt.run(
uid,
provider,
settings.api_key || null,
settings.selected_llm_model || null,
settings.selected_stt_model || null,
0, // is_active_llm - always 0, use setActiveProvider to activate
0, // is_active_stt - always 0, use setActiveProvider to activate
settings.created_at || Date.now(),
settings.updated_at
);
@ -39,24 +61,100 @@ function upsert(uid, provider, settings) {
return { changes: result.changes };
}
function remove(uid, provider) {
function remove(provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?');
const result = stmt.run(uid, provider);
const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?');
const result = stmt.run(provider);
return { changes: result.changes };
}
function removeAllByUid(uid) {
function removeAll() {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?');
const result = stmt.run(uid);
const stmt = db.prepare('DELETE FROM provider_settings');
const result = stmt.run();
return { changes: result.changes };
}
function getRawApiKeys() {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT api_key FROM provider_settings');
return stmt.all();
}
// Get active provider for a specific type (llm or stt)
function getActiveProvider(type) {
const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
const stmt = db.prepare(`SELECT * FROM provider_settings WHERE ${column} = 1`);
const result = stmt.get() || null;
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
return result;
}
// Set active provider for a specific type
function setActiveProvider(provider, type) {
const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
// Start transaction to ensure only one provider is active
db.transaction(() => {
// First, deactivate all providers for this type
const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0`);
deactivateStmt.run();
// Then activate the specified provider
if (provider) {
const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE provider = ?`);
activateStmt.run(provider);
}
})();
return { success: true };
}
// Get all active settings (both llm and stt)
function getActiveSettings() {
const db = sqliteClient.getDb();
const stmt = db.prepare(`
SELECT * FROM provider_settings
WHERE (is_active_llm = 1 OR is_active_stt = 1)
ORDER BY provider
`);
const results = stmt.all();
// Decrypt API keys and organize by type
const activeSettings = {
llm: null,
stt: null
};
results.forEach(result => {
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
if (result.is_active_llm) {
activeSettings.llm = result;
}
if (result.is_active_stt) {
activeSettings.stt = result;
}
});
return activeSettings;
}
module.exports = {
getByProvider,
getAllByUid,
getAll,
upsert,
remove,
removeAllByUid
removeAll,
getRawApiKeys,
getActiveProvider,
setActiveProvider,
getActiveSettings
};

View File

@ -1,14 +0,0 @@
const sqliteClient = require('../../services/sqliteClient');
async function markPermissionsAsCompleted() {
return sqliteClient.markPermissionsAsCompleted();
}
async function checkPermissionsCompleted() {
return sqliteClient.checkPermissionsCompleted();
}
module.exports = {
markPermissionsAsCompleted,
checkPermissionsCompleted,
};

View File

@ -3,15 +3,19 @@ const firebaseRepository = require('./firebase.repository');
let authService = null;
function setAuthService(service) {
authService = service;
function getAuthService() {
if (!authService) {
authService = require('../../services/authService');
}
return authService;
}
function getBaseRepository() {
if (!authService) {
throw new Error('AuthService has not been set for the user repository.');
const service = getAuthService();
if (!service) {
throw new Error('AuthService could not be loaded for the user repository.');
}
const user = authService.getCurrentUser();
const user = service.getCurrentUser();
if (user && user.isLoggedIn) {
return firebaseRepository;
}
@ -25,24 +29,23 @@ const userRepositoryAdapter = {
},
getById: () => {
const uid = authService.getCurrentUserId();
const uid = getAuthService().getCurrentUserId();
return getBaseRepository().getById(uid);
},
update: (updateData) => {
const uid = authService.getCurrentUserId();
const uid = getAuthService().getCurrentUserId();
return getBaseRepository().update({ uid, ...updateData });
},
deleteById: () => {
const uid = authService.getCurrentUserId();
const uid = getAuthService().getCurrentUserId();
return getBaseRepository().deleteById(uid);
}
};
module.exports = {
...userRepositoryAdapter,
setAuthService
...userRepositoryAdapter
};

View File

@ -1,55 +0,0 @@
const { collection, doc, getDoc, setDoc, deleteDoc } = require('firebase/firestore');
const { getFirestoreInstance: getFirestore } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter');
// Create encrypted converter for user model selections
const userModelSelectionsConverter = createEncryptedConverter([
'selected_llm_provider',
'selected_llm_model',
'selected_stt_provider',
'selected_stt_model'
]);
function userModelSelectionsCol() {
const db = getFirestore();
return collection(db, 'user_model_selections').withConverter(userModelSelectionsConverter);
}
async function get(uid) {
try {
const docRef = doc(userModelSelectionsCol(), uid);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? { id: docSnap.id, ...docSnap.data() } : null;
} catch (error) {
console.error('[UserModelSelections Firebase] Error getting user model selections:', error);
return null;
}
}
async function upsert(uid, selections) {
try {
const docRef = doc(userModelSelectionsCol(), uid);
await setDoc(docRef, selections, { merge: true });
return { changes: 1 };
} catch (error) {
console.error('[UserModelSelections Firebase] Error upserting user model selections:', error);
throw error;
}
}
async function remove(uid) {
try {
const docRef = doc(userModelSelectionsCol(), uid);
await deleteDoc(docRef);
return { changes: 1 };
} catch (error) {
console.error('[UserModelSelections Firebase] Error removing user model selections:', error);
throw error;
}
}
module.exports = {
get,
upsert,
remove
};

View File

@ -1,50 +0,0 @@
const firebaseRepository = require('./firebase.repository');
const sqliteRepository = require('./sqlite.repository');
let authService = null;
function setAuthService(service) {
authService = service;
}
function getBaseRepository() {
if (!authService) {
throw new Error('AuthService not set for userModelSelections repository');
}
const user = authService.getCurrentUser();
return user.isLoggedIn ? firebaseRepository : sqliteRepository;
}
const userModelSelectionsRepositoryAdapter = {
async get() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.get(uid);
},
async upsert(selections) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
const now = Date.now();
const selectionsWithMeta = {
...selections,
uid,
updated_at: now
};
return await repo.upsert(uid, selectionsWithMeta);
},
async remove() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.remove(uid);
}
};
module.exports = {
...userModelSelectionsRepositoryAdapter,
setAuthService
};

View File

@ -1,48 +0,0 @@
const sqliteClient = require('../../services/sqliteClient');
function get(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?');
return stmt.get(uid) || null;
}
function upsert(uid, selections) {
const db = sqliteClient.getDb();
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(`
INSERT INTO user_model_selections (uid, selected_llm_provider, selected_llm_model,
selected_stt_provider, selected_stt_model, updated_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(uid) DO UPDATE SET
selected_llm_provider = excluded.selected_llm_provider,
selected_llm_model = excluded.selected_llm_model,
selected_stt_provider = excluded.selected_stt_provider,
selected_stt_model = excluded.selected_stt_model,
updated_at = excluded.updated_at
`);
const result = stmt.run(
uid,
selections.selected_llm_provider || null,
selections.selected_llm_model || null,
selections.selected_stt_provider || null,
selections.selected_stt_model || null,
selections.updated_at
);
return { changes: result.changes };
}
function remove(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM user_model_selections WHERE uid = ?');
const result = stmt.run(uid);
return { changes: result.changes };
}
module.exports = {
get,
upsert,
remove
};

View File

@ -1,12 +1,12 @@
const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
const { BrowserWindow } = require('electron');
const { BrowserWindow, shell } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient');
const fetch = require('node-fetch');
const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session');
const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections');
const permissionService = require('./permissionService');
async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) {
@ -43,23 +43,14 @@ class AuthService {
this.isInitialized = false;
// This ensures the key is ready before any login/logout state change.
encryptionService.initializeKey(this.currentUserId);
this.initializationPromise = null;
sessionRepository.setAuthService(this);
providerSettingsRepository.setAuthService(this);
userModelSelectionsRepository.setAuthService(this);
}
initialize() {
if (this.isInitialized) return this.initializationPromise;
// --- Break the circular dependency ---
// Inject this authService instance into the session repository so it can be used
// without a direct `require` cycle.
sessionRepository.setAuthService(this);
// --- End of dependency injection ---
this.initializationPromise = new Promise((resolve) => {
const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => {
@ -75,29 +66,32 @@ class AuthService {
// Clean up any zombie sessions from a previous run for this user.
await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the logged-in user **
await encryptionService.initializeKey(user.uid);
// ** Initialize encryption key for the logged-in user if permissions are already granted **
if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) {
console.warn('[AuthService] Keychain permission not yet completed for this user. Deferring key initialization.');
} else {
await encryptionService.initializeKey(user.uid);
}
// ** Check for and run data migration for the user **
// No 'await' here, so it runs in the background without blocking startup.
migrationService.checkAndRunMigration(user);
// ***** CRITICAL: Wait for the virtual key and model state update to complete *****
try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
// Start background task to fetch and save virtual key
(async () => {
try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(virtualKey);
}
console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
} catch (error) {
console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
if (global.modelStateService) {
// The model state service now writes directly to the DB, no in-memory state.
await global.modelStateService.setFirebaseVirtualKey(virtualKey);
}
})();
console.log(`[AuthService] Virtual key for ${user.email} has been processed and state updated.`);
} catch (error) {
console.error('[AuthService] Failed to fetch or save virtual key:', error);
// This is not critical enough to halt the login, but we should log it.
}
} else {
// User signed OUT
@ -105,7 +99,8 @@ class AuthService {
if (previousUser) {
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(null);
// The model state service now writes directly to the DB.
await global.modelStateService.setFirebaseVirtualKey(null);
}
}
this.currentUser = null;
@ -115,8 +110,7 @@ class AuthService {
// End active sessions for the local/default user as well.
await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the default/local user **
await encryptionService.initializeKey(this.currentUserId);
encryptionService.resetSessionKey();
}
this.broadcastUserState();
@ -131,6 +125,19 @@ class AuthService {
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) {
const auth = getFirebaseAuth();
try {
@ -168,7 +175,6 @@ class AuthService {
});
}
getCurrentUserId() {
return this.currentUserId;
}

View File

@ -10,10 +10,13 @@ class DatabaseInitializer {
// 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
const userDataPath = app.getPath('userData');
// In both development and production mode, the database is stored in the userData directory:
// macOS: ~/Library/Application Support/Glass/pickleglass.db
// Windows: %APPDATA%\Glass\pickleglass.db
this.dbPath = path.join(userDataPath, 'pickleglass.db');
this.dataDir = userDataPath;
// 원본 DB 경로 (패키지 내 읽기 전용 위치)
// The original DB path (read-only location in the package)
this.sourceDbPath = app.isPackaged
? path.join(process.resourcesPath, 'data', 'pickleglass.db')
: path.join(app.getAppPath(), 'data', 'pickleglass.db');
@ -52,7 +55,7 @@ class DatabaseInitializer {
try {
this.ensureDatabaseExists();
await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
// This single call will now synchronize the schema and then init default data.
await sqliteClient.initTables();

View File

@ -9,6 +9,8 @@ try {
keytar = null;
}
const permissionService = require('./permissionService');
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
let sessionKey = null; // In-memory fallback key
@ -31,6 +33,8 @@ async function initializeKey(userId) {
throw new Error('A user ID must be provided to initialize the encryption key.');
}
let keyRetrieved = false;
if (keytar) {
try {
let key = await keytar.getPassword(SERVICE_NAME, userId);
@ -41,6 +45,7 @@ async function initializeKey(userId) {
console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
} else {
console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
keyRetrieved = true;
}
sessionKey = key;
} catch (error) {
@ -55,12 +60,26 @@ async function initializeKey(userId) {
sessionKey = crypto.randomBytes(32).toString('hex');
}
}
// Mark keychain completed in permissions DB if this is the first successful retrieval or storage
try {
await permissionService.markKeychainCompleted(userId);
if (keyRetrieved) {
console.log(`[EncryptionService] Keychain completion marked in DB for ${userId}.`);
}
} catch (permErr) {
console.error('[EncryptionService] Failed to mark keychain completion:', permErr);
}
if (!sessionKey) {
throw new Error('Failed to initialize encryption key.');
}
}
function resetSessionKey() {
sessionKey = null;
}
/**
* Encrypts a given text using AES-256-GCM.
* @param {string} text The text to encrypt.
@ -129,12 +148,28 @@ function decrypt(encryptedText) {
} catch (error) {
// It's common for this to fail if the data is not encrypted (e.g., legacy data).
// In that case, we return the original value.
console.error('[EncryptionService] Decryption failed:', error);
return encryptedText;
}
}
function looksEncrypted(str) {
if (!str || typeof str !== 'string') return false;
// Base64 chars + optional '=' padding
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(str)) return false;
try {
const buf = Buffer.from(str, 'base64');
// Our AES-GCM cipher text must be at least 32 bytes (IV 16 + TAG 16)
return buf.length >= 32;
} catch {
return false;
}
}
module.exports = {
initializeKey,
resetSessionKey,
encrypt,
decrypt,
looksEncrypted,
};

View File

@ -0,0 +1,639 @@
const { EventEmitter } = require('events');
const ollamaService = require('./ollamaService');
const whisperService = require('./whisperService');
//Central manager for managing Ollama and Whisper services
class LocalAIManager extends EventEmitter {
constructor() {
super();
// service map
this.services = {
ollama: ollamaService,
whisper: whisperService
};
// unified state management
this.state = {
ollama: {
installed: false,
running: false,
models: []
},
whisper: {
installed: false,
initialized: false,
models: []
}
};
// setup event listeners
this.setupEventListeners();
}
// subscribe to events from each service and re-emit as unified events
setupEventListeners() {
// ollama events
ollamaService.on('install-progress', (data) => {
this.emit('install-progress', 'ollama', data);
});
ollamaService.on('installation-complete', () => {
this.emit('installation-complete', 'ollama');
this.updateServiceState('ollama');
});
ollamaService.on('error', (error) => {
this.emit('error', { service: 'ollama', ...error });
});
ollamaService.on('model-pull-complete', (data) => {
this.emit('model-ready', { service: 'ollama', ...data });
this.updateServiceState('ollama');
});
ollamaService.on('state-changed', (state) => {
this.emit('state-changed', 'ollama', state);
});
// Whisper 이벤트
whisperService.on('install-progress', (data) => {
this.emit('install-progress', 'whisper', data);
});
whisperService.on('installation-complete', () => {
this.emit('installation-complete', 'whisper');
this.updateServiceState('whisper');
});
whisperService.on('error', (error) => {
this.emit('error', { service: 'whisper', ...error });
});
whisperService.on('model-download-complete', (data) => {
this.emit('model-ready', { service: 'whisper', ...data });
this.updateServiceState('whisper');
});
}
/**
* 서비스 설치
*/
async installService(serviceName, options = {}) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
try {
if (serviceName === 'ollama') {
return await service.handleInstall();
} else if (serviceName === 'whisper') {
// Whisper는 자동 설치
await service.initialize();
return { success: true };
}
} catch (error) {
this.emit('error', {
service: serviceName,
errorType: 'installation-failed',
error: error.message
});
throw error;
}
}
/**
* 서비스 상태 조회
*/
async getServiceStatus(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
if (serviceName === 'ollama') {
return await service.getStatus();
} else if (serviceName === 'whisper') {
const installed = await service.isInstalled();
const running = await service.isServiceRunning();
const models = await service.getInstalledModels();
return {
success: true,
installed,
running,
models
};
}
}
/**
* 서비스 시작
*/
async startService(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
const result = await service.startService();
await this.updateServiceState(serviceName);
return { success: result };
}
/**
* 서비스 중지
*/
async stopService(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
let result;
if (serviceName === 'ollama') {
result = await service.shutdown(false);
} else if (serviceName === 'whisper') {
result = await service.stopService();
}
// 서비스 중지 후 상태 업데이트
await this.updateServiceState(serviceName);
return result;
}
/**
* 모델 설치/다운로드
*/
async installModel(serviceName, modelId, options = {}) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
if (serviceName === 'ollama') {
return await service.pullModel(modelId);
} else if (serviceName === 'whisper') {
return await service.downloadModel(modelId);
}
}
/**
* 설치된 모델 목록 조회
*/
async getInstalledModels(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
if (serviceName === 'ollama') {
return await service.getAllModelsWithStatus();
} else if (serviceName === 'whisper') {
return await service.getInstalledModels();
}
}
/**
* 모델 워밍업 (Ollama 전용)
*/
async warmUpModel(modelName, forceRefresh = false) {
return await ollamaService.warmUpModel(modelName, forceRefresh);
}
/**
* 자동 워밍업 (Ollama 전용)
*/
async autoWarmUp() {
return await ollamaService.autoWarmUpSelectedModel();
}
/**
* 진단 실행
*/
async runDiagnostics(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
const diagnostics = {
service: serviceName,
timestamp: new Date().toISOString(),
checks: {}
};
try {
// 1. 설치 상태 확인
diagnostics.checks.installation = {
check: 'Installation',
status: await service.isInstalled() ? 'pass' : 'fail',
details: {}
};
// 2. 서비스 실행 상태
diagnostics.checks.running = {
check: 'Service Running',
status: await service.isServiceRunning() ? 'pass' : 'fail',
details: {}
};
// 3. 포트 연결 테스트 및 상세 health check (Ollama)
if (serviceName === 'ollama') {
try {
// Use comprehensive health check
const health = await service.healthCheck();
diagnostics.checks.health = {
check: 'Service Health',
status: health.healthy ? 'pass' : 'fail',
details: health
};
// Legacy port check for compatibility
diagnostics.checks.port = {
check: 'Port Connectivity',
status: health.checks.apiResponsive ? 'pass' : 'fail',
details: { connected: health.checks.apiResponsive }
};
} catch (error) {
diagnostics.checks.health = {
check: 'Service Health',
status: 'fail',
details: { error: error.message }
};
diagnostics.checks.port = {
check: 'Port Connectivity',
status: 'fail',
details: { error: error.message }
};
}
// 4. 모델 목록
if (diagnostics.checks.running.status === 'pass') {
try {
const models = await service.getInstalledModels();
diagnostics.checks.models = {
check: 'Installed Models',
status: 'pass',
details: { count: models.length, models: models.map(m => m.name) }
};
// 5. 워밍업 상태
const warmupStatus = await service.getWarmUpStatus();
diagnostics.checks.warmup = {
check: 'Model Warm-up',
status: 'pass',
details: warmupStatus
};
} catch (error) {
diagnostics.checks.models = {
check: 'Installed Models',
status: 'fail',
details: { error: error.message }
};
}
}
}
// 4. Whisper 특화 진단
if (serviceName === 'whisper') {
// 바이너리 확인
diagnostics.checks.binary = {
check: 'Whisper Binary',
status: service.whisperPath ? 'pass' : 'fail',
details: { path: service.whisperPath }
};
// 모델 디렉토리
diagnostics.checks.modelDir = {
check: 'Model Directory',
status: service.modelsDir ? 'pass' : 'fail',
details: { path: service.modelsDir }
};
}
// 전체 진단 결과
const allChecks = Object.values(diagnostics.checks);
diagnostics.summary = {
total: allChecks.length,
passed: allChecks.filter(c => c.status === 'pass').length,
failed: allChecks.filter(c => c.status === 'fail').length,
overallStatus: allChecks.every(c => c.status === 'pass') ? 'healthy' : 'unhealthy'
};
} catch (error) {
diagnostics.error = error.message;
diagnostics.summary = {
overallStatus: 'error'
};
}
return diagnostics;
}
/**
* 서비스 복구
*/
async repairService(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
console.log(`[LocalAIManager] Starting repair for ${serviceName}...`);
const repairLog = [];
try {
// 1. 진단 실행
repairLog.push('Running diagnostics...');
const diagnostics = await this.runDiagnostics(serviceName);
if (diagnostics.summary.overallStatus === 'healthy') {
repairLog.push('Service is already healthy, no repair needed');
return {
success: true,
repairLog,
diagnostics
};
}
// 2. 설치 문제 해결
if (diagnostics.checks.installation?.status === 'fail') {
repairLog.push('Installation missing, attempting to install...');
try {
await this.installService(serviceName);
repairLog.push('Installation completed');
} catch (error) {
repairLog.push(`Installation failed: ${error.message}`);
throw error;
}
}
// 3. 서비스 재시작
if (diagnostics.checks.running?.status === 'fail') {
repairLog.push('Service not running, attempting to start...');
// 종료 시도
try {
await this.stopService(serviceName);
repairLog.push('Stopped existing service');
} catch (error) {
repairLog.push('Service was not running');
}
// 잠시 대기
await new Promise(resolve => setTimeout(resolve, 2000));
// 시작
try {
await this.startService(serviceName);
repairLog.push('Service started successfully');
} catch (error) {
repairLog.push(`Failed to start service: ${error.message}`);
throw error;
}
}
// 4. 포트 문제 해결 (Ollama)
if (serviceName === 'ollama' && diagnostics.checks.port?.status === 'fail') {
repairLog.push('Port connectivity issue detected');
// 프로세스 강제 종료
if (process.platform === 'darwin') {
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
await execAsync('pkill -f ollama');
repairLog.push('Killed stale Ollama processes');
} catch (error) {
repairLog.push('No stale processes found');
}
}
else if (process.platform === 'win32') {
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
await execAsync('taskkill /F /IM ollama.exe');
repairLog.push('Killed stale Ollama processes');
} catch (error) {
repairLog.push('No stale processes found');
}
}
else if (process.platform === 'linux') {
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
await execAsync('pkill -f ollama');
repairLog.push('Killed stale Ollama processes');
} catch (error) {
repairLog.push('No stale processes found');
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
// 재시작
await this.startService(serviceName);
repairLog.push('Restarted service after port cleanup');
}
// 5. Whisper 특화 복구
if (serviceName === 'whisper') {
// 세션 정리
if (diagnostics.checks.running?.status === 'pass') {
repairLog.push('Cleaning up Whisper sessions...');
await service.cleanup();
repairLog.push('Sessions cleaned up');
}
// 초기화
if (!service.installState.isInitialized) {
repairLog.push('Re-initializing Whisper...');
await service.initialize();
repairLog.push('Whisper re-initialized');
}
}
// 6. 최종 상태 확인
repairLog.push('Verifying repair...');
const finalDiagnostics = await this.runDiagnostics(serviceName);
const success = finalDiagnostics.summary.overallStatus === 'healthy';
repairLog.push(success ? 'Repair successful!' : 'Repair failed - manual intervention may be required');
// 성공 시 상태 업데이트
if (success) {
await this.updateServiceState(serviceName);
}
return {
success,
repairLog,
diagnostics: finalDiagnostics
};
} catch (error) {
repairLog.push(`Repair error: ${error.message}`);
return {
success: false,
repairLog,
error: error.message
};
}
}
/**
* 상태 업데이트
*/
async updateServiceState(serviceName) {
try {
const status = await this.getServiceStatus(serviceName);
this.state[serviceName] = status;
// 상태 변경 이벤트 발행
this.emit('state-changed', serviceName, status);
} catch (error) {
console.error(`[LocalAIManager] Failed to update ${serviceName} state:`, error);
}
}
/**
* 전체 상태 조회
*/
async getAllServiceStates() {
const states = {};
for (const serviceName of Object.keys(this.services)) {
try {
states[serviceName] = await this.getServiceStatus(serviceName);
} catch (error) {
states[serviceName] = {
success: false,
error: error.message
};
}
}
return states;
}
/**
* 주기적 상태 동기화 시작
*/
startPeriodicSync(interval = 30000) {
if (this.syncInterval) {
return;
}
this.syncInterval = setInterval(async () => {
for (const serviceName of Object.keys(this.services)) {
await this.updateServiceState(serviceName);
}
}, interval);
// 각 서비스의 주기적 동기화도 시작
ollamaService.startPeriodicSync();
}
/**
* 주기적 상태 동기화 중지
*/
stopPeriodicSync() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
// 각 서비스의 주기적 동기화도 중지
ollamaService.stopPeriodicSync();
}
/**
* 전체 종료
*/
async shutdown() {
this.stopPeriodicSync();
const results = {};
for (const [serviceName, service] of Object.entries(this.services)) {
try {
if (serviceName === 'ollama') {
results[serviceName] = await service.shutdown(false);
} else if (serviceName === 'whisper') {
await service.cleanup();
results[serviceName] = true;
}
} catch (error) {
results[serviceName] = false;
console.error(`[LocalAIManager] Failed to shutdown ${serviceName}:`, error);
}
}
return results;
}
/**
* 에러 처리
*/
async handleError(serviceName, errorType, details = {}) {
console.error(`[LocalAIManager] Error in ${serviceName}: ${errorType}`, details);
// 서비스별 에러 처리
switch(errorType) {
case 'installation-failed':
// 설치 실패 시 이벤트 발생
this.emit('error-occurred', {
service: serviceName,
errorType,
error: details.error || 'Installation failed',
canRetry: true
});
break;
case 'model-pull-failed':
case 'model-download-failed':
// 모델 다운로드 실패
this.emit('error-occurred', {
service: serviceName,
errorType,
model: details.model,
error: details.error || 'Model download failed',
canRetry: true
});
break;
case 'service-not-responding':
// 서비스 반응 없음
console.log(`[LocalAIManager] Attempting to repair ${serviceName}...`);
const repairResult = await this.repairService(serviceName);
this.emit('error-occurred', {
service: serviceName,
errorType,
error: details.error || 'Service not responding',
repairAttempted: true,
repairSuccessful: repairResult.success
});
break;
default:
// 기타 에러
this.emit('error-occurred', {
service: serviceName,
errorType,
error: details.error || `Unknown error: ${errorType}`,
canRetry: false
});
}
}
}
// 싱글톤
const localAIManager = new LocalAIManager();
module.exports = localAIManager;

View File

@ -1,277 +0,0 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const { EventEmitter } = require('events');
const path = require('path');
const os = require('os');
const https = require('https');
const fs = require('fs');
const crypto = require('crypto');
const execAsync = promisify(exec);
class LocalAIServiceBase extends EventEmitter {
constructor(serviceName) {
super();
this.serviceName = serviceName;
this.baseUrl = null;
this.installationProgress = new Map();
}
getPlatform() {
return process.platform;
}
async checkCommand(command) {
try {
const platform = this.getPlatform();
const checkCmd = platform === 'win32' ? 'where' : 'which';
const { stdout } = await execAsync(`${checkCmd} ${command}`);
return stdout.trim();
} catch (error) {
return null;
}
}
async isInstalled() {
throw new Error('isInstalled() must be implemented by subclass');
}
async isServiceRunning() {
throw new Error('isServiceRunning() must be implemented by subclass');
}
async startService() {
throw new Error('startService() must be implemented by subclass');
}
async stopService() {
throw new Error('stopService() must be implemented by subclass');
}
async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) {
for (let i = 0; i < maxAttempts; i++) {
if (await checkFn()) {
console.log(`[${this.serviceName}] Service is ready`);
return true;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
throw new Error(`${this.serviceName} service failed to start within timeout`);
}
getInstallProgress(modelName) {
return this.installationProgress.get(modelName) || 0;
}
setInstallProgress(modelName, progress) {
this.installationProgress.set(modelName, progress);
this.emit('install-progress', { model: modelName, progress });
}
clearInstallProgress(modelName) {
this.installationProgress.delete(modelName);
}
async autoInstall(onProgress) {
const platform = this.getPlatform();
console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`);
try {
switch(platform) {
case 'darwin':
return await this.installMacOS(onProgress);
case 'win32':
return await this.installWindows(onProgress);
case 'linux':
return await this.installLinux();
default:
throw new Error(`Unsupported platform: ${platform}`);
}
} catch (error) {
console.error(`[${this.serviceName}] Auto-installation failed:`, error);
throw error;
}
}
async installMacOS() {
throw new Error('installMacOS() must be implemented by subclass');
}
async installWindows() {
throw new Error('installWindows() must be implemented by subclass');
}
async installLinux() {
throw new Error('installLinux() must be implemented by subclass');
}
// parseProgress method removed - using proper REST API now
async shutdown(force = false) {
console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`);
const isRunning = await this.isServiceRunning();
if (!isRunning) {
console.log(`[${this.serviceName}] Service not running, nothing to shutdown`);
return true;
}
const platform = this.getPlatform();
try {
switch(platform) {
case 'darwin':
return await this.shutdownMacOS(force);
case 'win32':
return await this.shutdownWindows(force);
case 'linux':
return await this.shutdownLinux(force);
default:
console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`);
return false;
}
} catch (error) {
console.error(`[${this.serviceName}] Error during shutdown:`, error);
return false;
}
}
async shutdownMacOS(force) {
throw new Error('shutdownMacOS() must be implemented by subclass');
}
async shutdownWindows(force) {
throw new Error('shutdownWindows() must be implemented by subclass');
}
async shutdownLinux(force) {
throw new Error('shutdownLinux() must be implemented by subclass');
}
async downloadFile(url, destination, options = {}) {
const {
onProgress = null,
headers = { 'User-Agent': 'Glass-App' },
timeout = 300000 // 5 minutes default
} = options;
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
let downloadedSize = 0;
let totalSize = 0;
const request = https.get(url, { headers }, (response) => {
// Handle redirects (301, 302, 307, 308)
if ([301, 302, 307, 308].includes(response.statusCode)) {
file.close();
fs.unlink(destination, () => {});
if (!response.headers.location) {
reject(new Error('Redirect without location header'));
return;
}
console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`);
this.downloadFile(response.headers.location, destination, options)
.then(resolve)
.catch(reject);
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlink(destination, () => {});
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`));
return;
}
totalSize = parseInt(response.headers['content-length'], 10) || 0;
response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (onProgress && totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100);
onProgress(progress, downloadedSize, totalSize);
}
});
response.pipe(file);
file.on('finish', () => {
file.close(() => {
this.emit('download-complete', { url, destination, size: downloadedSize });
resolve({ success: true, size: downloadedSize });
});
});
});
request.on('timeout', () => {
request.destroy();
file.close();
fs.unlink(destination, () => {});
reject(new Error('Download timeout'));
});
request.on('error', (err) => {
file.close();
fs.unlink(destination, () => {});
this.emit('download-error', { url, error: err });
reject(err);
});
request.setTimeout(timeout);
file.on('error', (err) => {
fs.unlink(destination, () => {});
reject(err);
});
});
}
async downloadWithRetry(url, destination, options = {}) {
const { maxRetries = 3, retryDelay = 1000, expectedChecksum = null, ...downloadOptions } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.downloadFile(url, destination, downloadOptions);
if (expectedChecksum) {
const isValid = await this.verifyChecksum(destination, expectedChecksum);
if (!isValid) {
fs.unlinkSync(destination);
throw new Error('Checksum verification failed');
}
console.log(`[${this.serviceName}] Checksum verified successfully`);
}
return result;
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
}
}
}
async verifyChecksum(filePath, expectedChecksum) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => {
const fileChecksum = hash.digest('hex');
console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`);
console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`);
resolve(fileChecksum === expectedChecksum);
});
stream.on('error', reject);
});
}
}
module.exports = LocalAIServiceBase;

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

View File

@ -1,581 +1,437 @@
const { EventEmitter } = require('events');
const Store = require('electron-store');
const fetch = require('node-fetch');
const { ipcMain, webContents } = require('electron');
const { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections');
const authService = require('./authService');
const ollamaModelRepository = require('../repositories/ollamaModel');
class ModelStateService {
constructor(authService) {
class ModelStateService extends EventEmitter {
constructor() {
super();
this.authService = authService;
// electron-store는 오직 레거시 데이터 마이그레이션 용도로만 사용됩니다.
this.store = new Store({ name: 'pickle-glass-model-state' });
this.state = {};
this.hasMigrated = false;
// Set auth service for repositories
providerSettingsRepository.setAuthService(authService);
userModelSelectionsRepository.setAuthService(authService);
}
async initialize() {
console.log('[ModelStateService] Initializing...');
await this._loadStateForCurrentUser();
this.setupIpcHandlers();
console.log('[ModelStateService] Initialization complete');
console.log('[ModelStateService] Initializing one-time setup...');
await this._initializeEncryption();
await this._runMigrations();
this.setupLocalAIStateSync();
await this._autoSelectAvailableModels([], true);
console.log('[ModelStateService] One-time setup complete.');
}
_logCurrentSelection() {
const llmModel = this.state.selectedModels.llm;
const sttModel = this.state.selectedModels.stt;
const llmProvider = this.getProviderForModel('llm', llmModel) || 'None';
const sttProvider = this.getProviderForModel('stt', sttModel) || 'None';
async _initializeEncryption() {
try {
const rows = await providerSettingsRepository.getRawApiKeys();
if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) {
console.log('[ModelStateService] Encrypted keys detected, initializing encryption...');
const userIdForMigration = this.authService.getCurrentUserId();
await encryptionService.initializeKey(userIdForMigration);
} else {
console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.');
}
} catch (err) {
console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
}
}
async _runMigrations() {
console.log('[ModelStateService] Checking for data migrations...');
const userId = this.authService.getCurrentUserId();
try {
const sqliteClient = require('./sqliteClient');
const db = sqliteClient.getDb();
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_model_selections'").get();
if (tableExists) {
const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId);
if (selections) {
console.log('[ModelStateService] Migrating from user_model_selections table...');
if (selections.llm_model) {
const llmProvider = this.getProviderForModel(selections.llm_model, 'llm');
if (llmProvider) {
await this.setSelectedModel('llm', selections.llm_model);
}
}
if (selections.stt_model) {
const sttProvider = this.getProviderForModel(selections.stt_model, 'stt');
if (sttProvider) {
await this.setSelectedModel('stt', selections.stt_model);
}
}
db.prepare('DROP TABLE user_model_selections').run();
console.log('[ModelStateService] user_model_selections migration complete.');
}
}
} catch (error) {
console.error('[ModelStateService] user_model_selections migration failed:', error);
}
try {
const legacyData = this.store.get(`users.${userId}`);
if (legacyData && legacyData.apiKeys) {
console.log('[ModelStateService] Migrating from electron-store...');
for (const [provider, apiKey] of Object.entries(legacyData.apiKeys)) {
if (apiKey && PROVIDERS[provider]) {
await this.setApiKey(provider, apiKey);
}
}
if (legacyData.selectedModels?.llm) {
await this.setSelectedModel('llm', legacyData.selectedModels.llm);
}
if (legacyData.selectedModels?.stt) {
await this.setSelectedModel('stt', legacyData.selectedModels.stt);
}
this.store.delete(`users.${userId}`);
console.log('[ModelStateService] electron-store migration complete.');
}
} catch (error) {
console.error('[ModelStateService] electron-store migration failed:', error);
}
}
console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
setupLocalAIStateSync() {
const localAIManager = require('./localAIManager');
localAIManager.on('state-changed', (service, status) => {
this.handleLocalAIStateChange(service, status);
});
}
_autoSelectAvailableModels() {
console.log('[ModelStateService] Running auto-selection for models...');
async handleLocalAIStateChange(service, state) {
console.log(`[ModelStateService] LocalAI state changed: ${service}`, state);
if (!state.installed || !state.running) {
const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : [];
await this._autoSelectAvailableModels(types);
}
this.emit('state-updated', await this.getLiveState());
}
async getLiveState() {
const providerSettings = await providerSettingsRepository.getAll();
const apiKeys = {};
Object.keys(PROVIDERS).forEach(provider => {
const setting = providerSettings.find(s => s.provider === provider);
apiKeys[provider] = setting?.api_key || null;
});
const activeSettings = await providerSettingsRepository.getActiveSettings();
const selectedModels = {
llm: activeSettings.llm?.selected_llm_model || null,
stt: activeSettings.stt?.selected_stt_model || null
};
return { apiKeys, selectedModels };
}
async _autoSelectAvailableModels(forceReselectionForTypes = [], isInitialBoot = false) {
console.log(`[ModelStateService] Running auto-selection. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
const { apiKeys, selectedModels } = await this.getLiveState();
const types = ['llm', 'stt'];
types.forEach(type => {
const currentModelId = this.state.selectedModels[type];
for (const type of types) {
const currentModelId = selectedModels[type];
let isCurrentModelValid = false;
const forceReselection = forceReselectionForTypes.includes(type);
if (currentModelId) {
const provider = this.getProviderForModel(type, currentModelId);
const apiKey = this.getApiKey(provider);
// For Ollama, 'local' is a valid API key
if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) {
if (currentModelId && !forceReselection) {
const provider = this.getProviderForModel(currentModelId, type);
const apiKey = apiKeys[provider];
if (provider && apiKey) {
isCurrentModelValid = true;
}
}
if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`);
const availableModels = this.getAvailableModels(type);
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`);
const availableModels = await this.getAvailableModels(type);
if (availableModels.length > 0) {
// Prefer API providers over local providers for auto-selection
const apiModel = availableModels.find(model => {
const provider = this.getProviderForModel(type, model.id);
const provider = this.getProviderForModel(model.id, type);
return provider && provider !== 'ollama' && provider !== 'whisper';
});
const selectedModel = apiModel || availableModels[0];
this.state.selectedModels[type] = selectedModel.id;
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${selectedModel.id} (preferred: ${apiModel ? 'API' : 'local'})`);
const newModel = apiModel || availableModels[0];
await this.setSelectedModel(type, newModel.id);
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`);
} else {
this.state.selectedModels[type] = null;
}
}
});
}
async _migrateFromElectronStore() {
console.log('[ModelStateService] Starting migration from electron-store to database...');
const userId = this.authService.getCurrentUserId();
try {
// Get data from electron-store
const legacyData = this.store.get(`users.${userId}`, null);
if (!legacyData) {
console.log('[ModelStateService] No legacy data to migrate');
return;
}
console.log('[ModelStateService] Found legacy data, migrating...');
// Migrate provider settings (API keys and selected models per provider)
const { apiKeys = {}, selectedModels = {} } = legacyData;
for (const [provider, apiKey] of Object.entries(apiKeys)) {
if (apiKey && PROVIDERS[provider]) {
// For encrypted keys, they are already decrypted in _loadStateForCurrentUser
await providerSettingsRepository.upsert(provider, {
api_key: apiKey
});
console.log(`[ModelStateService] Migrated API key for ${provider}`);
}
}
// Migrate global model selections
if (selectedModels.llm || selectedModels.stt) {
const llmProvider = selectedModels.llm ? this.getProviderForModel('llm', selectedModels.llm) : null;
const sttProvider = selectedModels.stt ? this.getProviderForModel('stt', selectedModels.stt) : null;
await userModelSelectionsRepository.upsert({
selected_llm_provider: llmProvider,
selected_llm_model: selectedModels.llm,
selected_stt_provider: sttProvider,
selected_stt_model: selectedModels.stt
});
console.log('[ModelStateService] Migrated global model selections');
}
// Mark migration as complete by removing legacy data
this.store.delete(`users.${userId}`);
console.log('[ModelStateService] Migration completed and legacy data cleaned up');
} catch (error) {
console.error('[ModelStateService] Migration failed:', error);
// Don't throw - continue with normal operation
}
}
async _loadStateFromDatabase() {
console.log('[ModelStateService] Loading state from database...');
const userId = this.authService.getCurrentUserId();
try {
// Load provider settings
const providerSettings = await providerSettingsRepository.getAllByUid();
const apiKeys = {};
// Reconstruct apiKeys object
Object.keys(PROVIDERS).forEach(provider => {
apiKeys[provider] = null;
});
for (const setting of providerSettings) {
if (setting.api_key) {
// API keys are stored encrypted in database, decrypt them
if (setting.provider !== 'ollama' && setting.provider !== 'whisper') {
try {
apiKeys[setting.provider] = encryptionService.decrypt(setting.api_key);
} catch (error) {
console.error(`[ModelStateService] Failed to decrypt API key for ${setting.provider}, resetting`);
apiKeys[setting.provider] = null;
}
} else {
apiKeys[setting.provider] = setting.api_key;
await providerSettingsRepository.setActiveProvider(null, type);
if (!isInitialBoot) {
this.emit('state-updated', await this.getLiveState());
}
}
}
// Load global model selections
const modelSelections = await userModelSelectionsRepository.get();
const selectedModels = {
llm: modelSelections?.selected_llm_model || null,
stt: modelSelections?.selected_stt_model || null
};
this.state = {
apiKeys,
selectedModels
};
console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
} catch (error) {
console.error('[ModelStateService] Failed to load state from database:', error);
// Fall back to default state
const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => {
acc[key] = null;
return acc;
}, {});
this.state = {
apiKeys: initialApiKeys,
selectedModels: { llm: null, stt: null },
};
}
}
async setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key.`);
async _loadStateForCurrentUser() {
const userId = this.authService.getCurrentUserId();
// Initialize encryption service for current user
await encryptionService.initializeKey(userId);
// Try to load from database first
await this._loadStateFromDatabase();
// Check if we need to migrate from electron-store
const legacyData = this.store.get(`users.${userId}`, null);
if (legacyData && !this.hasMigrated) {
await this._migrateFromElectronStore();
// Reload state after migration
await this._loadStateFromDatabase();
this.hasMigrated = true;
}
this._autoSelectAvailableModels();
await this._saveState();
this._logCurrentSelection();
}
// 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다.
const previousSettings = await providerSettingsRepository.getByProvider('openai-glass');
const wasPreviouslyConfigured = !!previousSettings?.api_key;
async _saveState() {
console.log('[ModelStateService] Saving state to database...');
const userId = this.authService.getCurrentUserId();
try {
// Save provider settings (API keys)
for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) {
if (apiKey) {
const encryptedKey = (provider !== 'ollama' && provider !== 'whisper')
? encryptionService.encrypt(apiKey)
: apiKey;
await providerSettingsRepository.upsert(provider, {
api_key: encryptedKey
});
} else {
// Remove empty API keys
await providerSettingsRepository.remove(provider);
}
// 항상 새로운 가상 키로 업데이트합니다.
await this.setApiKey('openai-glass', virtualKey);
if (virtualKey) {
// 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다.
if (!wasPreviouslyConfigured) {
console.log('[ModelStateService] First-time setup for openai-glass, setting default models.');
const llmModel = PROVIDERS['openai-glass']?.llmModels[0];
const sttModel = PROVIDERS['openai-glass']?.sttModels[0];
if (llmModel) await this.setSelectedModel('llm', llmModel.id);
if (sttModel) await this.setSelectedModel('stt', sttModel.id);
} else {
console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.');
}
} else {
// 로그아웃 시, 현재 활성화된 모델이 openai-glass인 경우에만 다른 모델로 전환합니다.
const selected = await this.getSelectedModels();
const llmProvider = this.getProviderForModel(selected.llm, 'llm');
const sttProvider = this.getProviderForModel(selected.stt, 'stt');
// Save global model selections
const llmProvider = this.state.selectedModels.llm ? this.getProviderForModel('llm', this.state.selectedModels.llm) : null;
const sttProvider = this.state.selectedModels.stt ? this.getProviderForModel('stt', this.state.selectedModels.stt) : null;
if (llmProvider || sttProvider || this.state.selectedModels.llm || this.state.selectedModels.stt) {
await userModelSelectionsRepository.upsert({
selected_llm_provider: llmProvider,
selected_llm_model: this.state.selectedModels.llm,
selected_stt_provider: sttProvider,
selected_stt_model: this.state.selectedModels.stt
});
const typesToReselect = [];
if (llmProvider === 'openai-glass') typesToReselect.push('llm');
if (sttProvider === 'openai-glass') typesToReselect.push('stt');
if (typesToReselect.length > 0) {
console.log('[ModelStateService] Logged out, re-selecting models for:', typesToReselect.join(', '));
await this._autoSelectAvailableModels(typesToReselect);
}
console.log(`[ModelStateService] State saved to database for user: ${userId}`);
this._logCurrentSelection();
} catch (error) {
console.error('[ModelStateService] Failed to save state to database:', error);
// Fall back to electron-store for now
this._saveStateToElectronStore();
}
}
_saveStateToElectronStore() {
console.log('[ModelStateService] Falling back to electron-store...');
const userId = this.authService.getCurrentUserId();
const stateToSave = {
...this.state,
apiKeys: { ...this.state.apiKeys }
async setApiKey(provider, key) {
console.log(`[ModelStateService] setApiKey for ${provider}`);
if (!provider) {
throw new Error('Provider is required');
}
// 'openai-glass'는 자체 인증 키를 사용하므로 유효성 검사를 건너뜁니다.
if (provider !== 'openai-glass') {
const validationResult = await this.validateApiKey(provider, key);
if (!validationResult.success) {
console.warn(`[ModelStateService] API key validation failed for ${provider}: ${validationResult.error}`);
return validationResult;
}
}
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
await providerSettingsRepository.upsert(provider, { ...existingSettings, api_key: finalKey });
// 키가 추가/변경되었으므로, 해당 provider의 모델을 자동 선택할 수 있는지 확인
await this._autoSelectAvailableModels([]);
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return { success: true };
}
async getAllApiKeys() {
const allSettings = await providerSettingsRepository.getAll();
const apiKeys = {};
allSettings.forEach(s => {
if (s.provider !== 'openai-glass') {
apiKeys[s.provider] = s.api_key;
}
});
return apiKeys;
}
async removeApiKey(provider) {
const setting = await providerSettingsRepository.getByProvider(provider);
if (setting && setting.api_key) {
await providerSettingsRepository.upsert(provider, { ...setting, api_key: null });
await this._autoSelectAvailableModels(['llm', 'stt']);
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return true;
}
return false;
}
/**
* 사용자가 Firebase에 로그인했는지 확인합니다.
*/
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
}
/**
* 유효한 API 키가 하나라도 설정되어 있는지 확인합니다.
*/
async hasValidApiKey() {
if (this.isLoggedInWithFirebase()) return true;
const allSettings = await providerSettingsRepository.getAll();
return allSettings.some(s => s.api_key && s.api_key.trim().length > 0);
}
getProviderForModel(arg1, arg2) {
// Compatibility: support both (type, modelId) old order and (modelId, type) new order
let type, modelId;
if (arg1 === 'llm' || arg1 === 'stt') {
type = arg1;
modelId = arg2;
} else {
modelId = arg1;
type = arg2;
}
if (!modelId || !type) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models && models.some(m => m.id === modelId)) {
return providerId;
}
}
if (type === 'llm') {
const installedModels = ollamaModelRepository.getInstalledModels();
if (installedModels.some(m => m.name === modelId)) return 'ollama';
}
return null;
}
async getSelectedModels() {
const active = await providerSettingsRepository.getActiveSettings();
return {
llm: active.llm?.selected_llm_model || null,
stt: active.stt?.selected_stt_model || null,
};
for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
if (key && provider !== 'ollama' && provider !== 'whisper') {
try {
stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
} catch (error) {
console.error(`[ModelStateService] Failed to encrypt API key for ${provider}`);
stateToSave.apiKeys[provider] = null;
}
}
}
async setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(modelId, type);
if (!provider) {
console.warn(`[ModelStateService] No provider found for model ${modelId}`);
return false;
}
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
const newSettings = { ...existingSettings };
if (type === 'llm') {
newSettings.selected_llm_model = modelId;
} else {
newSettings.selected_stt_model = modelId;
}
this.store.set(`users.${userId}`, stateToSave);
console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`);
this._logCurrentSelection();
await providerSettingsRepository.upsert(provider, newSettings);
await providerSettingsRepository.setActiveProvider(provider, type);
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (provider: ${provider})`);
if (type === 'llm' && provider === 'ollama') {
require('./localAIManager').warmUpModel(modelId).catch(err => console.warn(err));
}
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return true;
}
async getAvailableModels(type) {
const allSettings = await providerSettingsRepository.getAll();
const available = [];
const modelListKey = type === 'llm' ? 'llmModels' : 'sttModels';
for (const setting of allSettings) {
if (!setting.api_key) continue;
const providerId = setting.provider;
if (providerId === 'ollama' && type === 'llm') {
const installed = ollamaModelRepository.getInstalledModels();
available.push(...installed.map(m => ({ id: m.name, name: m.name })));
} else if (PROVIDERS[providerId]?.[modelListKey]) {
available.push(...PROVIDERS[providerId][modelListKey]);
}
}
return [...new Map(available.map(item => [item.id, item])).values()];
}
async getCurrentModelInfo(type) {
const activeSetting = await providerSettingsRepository.getActiveProvider(type);
if (!activeSetting) return null;
const model = type === 'llm' ? activeSetting.selected_llm_model : activeSetting.selected_stt_model;
if (!model) return null;
return {
provider: activeSetting.provider,
model: model,
apiKey: activeSetting.api_key,
};
}
// --- 핸들러 및 유틸리티 메서드 ---
async validateApiKey(provider, key) {
if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
return { success: false, error: 'API key cannot be empty.' };
}
const ProviderClass = getProviderClass(provider);
if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
// Default to success if no specific validator is found
console.warn(`[ModelStateService] No validateApiKey function for provider: ${provider}. Assuming valid.`);
return { success: true };
return { success: true };
}
try {
const result = await ProviderClass.validateApiKey(key);
if (result.success) {
console.log(`[ModelStateService] API key for ${provider} is valid.`);
} else {
console.log(`[ModelStateService] API key for ${provider} is invalid: ${result.error}`);
}
return result;
return await ProviderClass.validateApiKey(key);
} catch (error) {
console.error(`[ModelStateService] Error during ${provider} key validation:`, error);
return { success: false, error: 'An unexpected error occurred during validation.' };
}
}
getProviderConfig() {
const config = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
config[key] = rest;
}
return config;
}
setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`);
this.state.apiKeys['openai-glass'] = virtualKey;
const llmModels = PROVIDERS['openai-glass']?.llmModels;
const sttModels = PROVIDERS['openai-glass']?.sttModels;
// When logging in with Pickle, prioritize Pickle's models over existing selections
if (virtualKey && llmModels?.length > 0) {
this.state.selectedModels.llm = llmModels[0].id;
console.log(`[ModelStateService] Prioritized Pickle LLM model: ${llmModels[0].id}`);
}
if (virtualKey && sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id;
console.log(`[ModelStateService] Prioritized Pickle STT model: ${sttModels[0].id}`);
}
// If logging out (virtualKey is null), run auto-selection to find alternatives
if (!virtualKey) {
this._autoSelectAvailableModels();
}
this._saveState();
this._logCurrentSelection();
}
setApiKey(provider, key) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = key;
this._saveState();
return true;
}
return false;
}
getApiKey(provider) {
return this.state.apiKeys[provider];
}
getAllApiKeys() {
const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys;
return displayKeys;
}
removeApiKey(provider) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = null;
const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm);
if (llmProvider === provider) this.state.selectedModels.llm = null;
const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt);
if (sttProvider === provider) this.state.selectedModels.stt = null;
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
return true;
}
return false;
}
getProviderForModel(type, modelId) {
if (!modelId) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models.some(m => m.id === modelId)) {
return providerId;
async handleRemoveApiKey(provider) {
const success = await this.removeApiKey(provider);
if (success) {
const selectedModels = await this.getSelectedModels();
if (!selectedModels.llm && !selectedModels.stt) {
this.emit('force-show-apikey-header');
}
}
// If no provider was found, assume it could be a custom Ollama model
// if Ollama provider is configured (has a key).
if (type === 'llm' && this.state.apiKeys['ollama']) {
console.log(`[ModelStateService] Model '${modelId}' not found in PROVIDERS list, assuming it's a custom Ollama model.`);
return 'ollama';
}
return null;
return success;
}
getCurrentProvider(type) {
const selectedModel = this.state.selectedModels[type];
return this.getProviderForModel(type, selectedModel);
/*-------------- Compatibility Helpers --------------*/
async handleValidateKey(provider, key) {
return await this.setApiKey(provider, key);
}
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
async handleSetSelectedModel(type, modelId) {
return await this.setSelectedModel(type, modelId);
}
areProvidersConfigured() {
async areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true;
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'ollama') {
// Ollama uses dynamic models, so just check if configured (has 'local' key)
return key === 'local';
}
if (provider === 'whisper') {
// Whisper doesn't support LLM
return false;
}
return key && PROVIDERS[provider]?.llmModels.length > 0;
const allSettings = await providerSettingsRepository.getAll();
const apiKeyMap = {};
allSettings.forEach(s => apiKeyMap[s.provider] = s.api_key);
// LLM
const hasLlmKey = Object.entries(apiKeyMap).some(([provider, key]) => {
if (!key) return false;
if (provider === 'whisper') return false; // whisper는 LLM 없음
return PROVIDERS[provider]?.llmModels?.length > 0;
});
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'whisper') {
// Whisper has static model list and supports STT
return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0;
}
if (provider === 'ollama') {
// Ollama doesn't support STT yet
return false;
}
return key && PROVIDERS[provider]?.sttModels.length > 0;
});
const result = hasLlmKey && hasSttKey;
console.log(`[ModelStateService] areProvidersConfigured: LLM=${hasLlmKey}, STT=${hasSttKey}, result=${result}`);
return result;
}
hasValidApiKey() {
if (this.isLoggedInWithFirebase()) return true;
// Check if any provider has a valid API key
return Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'ollama' || provider === 'whisper') {
return key === 'local';
}
return key && key.trim().length > 0;
});
}
getAvailableModels(type) {
const available = [];
const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
if (key && PROVIDERS[providerId]?.[modelList]) {
available.push(...PROVIDERS[providerId][modelList]);
}
});
return [...new Map(available.map(item => [item.id, item])).values()];
}
getSelectedModels() {
return this.state.selectedModels;
}
setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(type, modelId);
if (provider && this.state.apiKeys[provider]) {
const previousModel = this.state.selectedModels[type];
this.state.selectedModels[type] = modelId;
this._saveState();
// Auto warm-up for Ollama LLM models when changed
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
this._autoWarmUpOllamaModel(modelId, previousModel);
}
return true;
}
return false;
}
/**
* Auto warm-up Ollama model when LLM selection changes
* @private
* @param {string} newModelId - The newly selected model
* @param {string} previousModelId - The previously selected model
*/
async _autoWarmUpOllamaModel(newModelId, previousModelId) {
try {
console.log(`[ModelStateService] 🔥 LLM model changed: ${previousModelId || 'None'}${newModelId}, triggering warm-up`);
// Get Ollama service if available
const ollamaService = require('./ollamaService');
if (!ollamaService) {
console.log('[ModelStateService] OllamaService not available for auto warm-up');
return;
}
// Delay warm-up slightly to allow UI to update first
setTimeout(async () => {
try {
console.log(`[ModelStateService] Starting background warm-up for: ${newModelId}`);
const success = await ollamaService.warmUpModel(newModelId);
if (success) {
console.log(`[ModelStateService] ✅ Successfully warmed up model: ${newModelId}`);
} else {
console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`);
}
} catch (error) {
console.log(`[ModelStateService] 🚫 Error during auto warm-up for ${newModelId}:`, error.message);
}
}, 500); // 500ms delay
} catch (error) {
console.error('[ModelStateService] Error in auto warm-up setup:', error);
}
}
/**
*
* @param {('llm' | 'stt')} type
* @returns {{provider: string, model: string, apiKey: string} | null}
*/
getCurrentModelInfo(type) {
this._logCurrentSelection();
const model = this.state.selectedModels[type];
if (!model) {
return null;
}
const provider = this.getProviderForModel(type, model);
if (!provider) {
return null;
}
const apiKey = this.getApiKey(provider);
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;
// STT
const hasSttKey = Object.entries(apiKeyMap).some(([provider, key]) => {
if (!key) return false;
if (provider === 'ollama') return false; // ollama는 STT 없음
return PROVIDERS[provider]?.sttModels?.length > 0 || provider === 'whisper';
});
return hasLlmKey && hasSttKey;
}
}
module.exports = ModelStateService;
const modelStateService = new ModelStateService();
module.exports = modelStateService;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
const { systemPreferences, shell, desktopCapturer } = require('electron');
const permissionRepository = require('../repositories/permission');
class PermissionService {
_getAuthService() {
return require('./authService');
}
async checkSystemPermissions() {
const permissions = {
microphone: 'unknown',
screen: 'unknown',
keychain: 'unknown',
needsSetup: true
};
try {
if (process.platform === 'darwin') {
permissions.microphone = systemPreferences.getMediaAccessStatus('microphone');
permissions.screen = systemPreferences.getMediaAccessStatus('screen');
permissions.keychain = await this.checkKeychainCompleted(this._getAuthService().getCurrentUserId()) ? 'granted' : 'unknown';
permissions.needsSetup = permissions.microphone !== 'granted' || permissions.screen !== 'granted' || permissions.keychain !== 'granted';
} else {
permissions.microphone = 'granted';
permissions.screen = 'granted';
permissions.keychain = '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',
keychain: '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 markKeychainCompleted() {
try {
await permissionRepository.markKeychainCompleted(this._getAuthService().getCurrentUserId());
console.log('[Permissions] Marked keychain as completed');
return { success: true };
} catch (error) {
console.error('[Permissions] Error marking keychain as completed:', error);
return { success: false, error: error.message };
}
}
async checkKeychainCompleted(uid) {
if (uid === "default_user") {
return true;
}
try {
const completed = permissionRepository.checkKeychainCompleted(uid);
console.log('[Permissions] Keychain completed status:', completed);
return completed;
} catch (error) {
console.error('[Permissions] Error checking keychain completed status:', error);
return false;
}
}
}
const permissionService = new PermissionService();
module.exports = permissionService;

View File

@ -40,8 +40,82 @@ class SQLiteClient {
return `"${identifier}"`;
}
synchronizeSchema() {
_migrateProviderSettings() {
const tablesInDb = this.getTablesFromDb();
if (!tablesInDb.includes('provider_settings')) {
return; // Table doesn't exist, no migration needed.
}
const providerSettingsInfo = this.db.prepare(`PRAGMA table_info(provider_settings)`).all();
const hasUidColumn = providerSettingsInfo.some(col => col.name === 'uid');
if (hasUidColumn) {
console.log('[DB Migration] Old provider_settings schema detected. Starting robust migration...');
try {
this.db.transaction(() => {
this.db.exec('ALTER TABLE provider_settings RENAME TO provider_settings_old');
console.log('[DB Migration] Renamed provider_settings to provider_settings_old');
this.createTable('provider_settings', LATEST_SCHEMA.provider_settings);
console.log('[DB Migration] Created new provider_settings table');
// Dynamically build the migration query for robustness
const oldColumnNames = this.db.prepare(`PRAGMA table_info(provider_settings_old)`).all().map(c => c.name);
const newColumnNames = LATEST_SCHEMA.provider_settings.columns.map(c => c.name);
const commonColumns = newColumnNames.filter(name => oldColumnNames.includes(name));
if (!commonColumns.includes('provider')) {
console.warn('[DB Migration] Old table is missing the "provider" column. Aborting migration for this table.');
this.db.exec('DROP TABLE provider_settings_old');
return;
}
const orderParts = [];
if (oldColumnNames.includes('updated_at')) orderParts.push('updated_at DESC');
if (oldColumnNames.includes('created_at')) orderParts.push('created_at DESC');
const orderByClause = orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : '';
const columnsForInsert = commonColumns.map(c => this._validateAndQuoteIdentifier(c)).join(', ');
const migrationQuery = `
INSERT INTO provider_settings (${columnsForInsert})
SELECT ${columnsForInsert}
FROM (
SELECT *, ROW_NUMBER() OVER(PARTITION BY provider ${orderByClause}) as rn
FROM provider_settings_old
)
WHERE rn = 1
`;
console.log(`[DB Migration] Executing robust migration query for columns: ${commonColumns.join(', ')}`);
const result = this.db.prepare(migrationQuery).run();
console.log(`[DB Migration] Migrated ${result.changes} rows to the new provider_settings table.`);
this.db.exec('DROP TABLE provider_settings_old');
console.log('[DB Migration] Dropped provider_settings_old table.');
})();
console.log('[DB Migration] provider_settings migration completed successfully.');
} catch (error) {
console.error('[DB Migration] Failed to migrate provider_settings table.', error);
// Try to recover by dropping the temp table if it exists
const oldTableExists = this.getTablesFromDb().includes('provider_settings_old');
if (oldTableExists) {
this.db.exec('DROP TABLE provider_settings_old');
console.warn('[DB Migration] Cleaned up temporary old table after failure.');
}
throw error;
}
}
}
async synchronizeSchema() {
console.log('[DB Sync] Starting schema synchronization...');
// Run special migration for provider_settings before the generic sync logic
this._migrateProviderSettings();
const tablesInDb = this.getTablesFromDb();
for (const tableName of Object.keys(LATEST_SCHEMA)) {
@ -132,8 +206,8 @@ class SQLiteClient {
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
}
initTables() {
this.synchronizeSchema();
async initTables() {
await this.synchronizeSchema();
this.initDefaultData();
}
@ -166,21 +240,6 @@ class SQLiteClient {
console.log('Default data initialized.');
}
markPermissionsAsCompleted() {
return this.query(
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
['permissions_completed', 'true']
);
}
checkPermissionsCompleted() {
const result = this.query(
'SELECT value FROM system_settings WHERE key = ?',
['permissions_completed']
);
return result.length > 0 && result[0].value === 'true';
}
close() {
if (this.db) {
try {

View File

@ -1,20 +1,40 @@
const { spawn } = require('child_process');
const { EventEmitter } = require('events');
const { spawn, exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs');
const os = require('os');
const LocalAIServiceBase = require('./localAIServiceBase');
const https = require('https');
const crypto = require('crypto');
const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
const execAsync = promisify(exec);
const fsPromises = fs.promises;
class WhisperService extends LocalAIServiceBase {
class WhisperService extends EventEmitter {
constructor() {
super('WhisperService');
this.isInitialized = false;
super();
this.serviceName = 'WhisperService';
// 경로 및 디렉토리
this.whisperPath = null;
this.modelsDir = null;
this.tempDir = null;
// 세션 관리 (세션 풀 내장)
this.sessionPool = [];
this.activeSessions = new Map();
this.maxSessions = 3;
// 설치 상태
this.installState = {
isInstalled: false,
isInitialized: false
};
// 사용 가능한 모델
this.availableModels = {
'whisper-tiny': {
name: 'Tiny',
@ -39,8 +59,222 @@ class WhisperService extends LocalAIServiceBase {
};
}
// Base class methods integration
getPlatform() {
return process.platform;
}
async checkCommand(command) {
try {
const platform = this.getPlatform();
const checkCmd = platform === 'win32' ? 'where' : 'which';
const { stdout } = await execAsync(`${checkCmd} ${command}`);
return stdout.trim();
} catch (error) {
return null;
}
}
async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) {
for (let i = 0; i < maxAttempts; i++) {
if (await checkFn()) {
console.log(`[${this.serviceName}] Service is ready`);
return true;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
throw new Error(`${this.serviceName} service failed to start within timeout`);
}
async downloadFile(url, destination, options = {}) {
const {
onProgress = null,
headers = { 'User-Agent': 'Glass-App' },
timeout = 300000,
modelId = null
} = options;
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
let downloadedSize = 0;
let totalSize = 0;
const request = https.get(url, { headers }, (response) => {
if ([301, 302, 307, 308].includes(response.statusCode)) {
file.close();
fs.unlink(destination, () => {});
if (!response.headers.location) {
reject(new Error('Redirect without location header'));
return;
}
console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`);
this.downloadFile(response.headers.location, destination, options)
.then(resolve)
.catch(reject);
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlink(destination, () => {});
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`));
return;
}
totalSize = parseInt(response.headers['content-length'], 10) || 0;
response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100);
if (onProgress) {
onProgress(progress, downloadedSize, totalSize);
}
}
});
response.pipe(file);
file.on('finish', () => {
file.close(() => {
resolve({ success: true, size: downloadedSize });
});
});
});
request.on('timeout', () => {
request.destroy();
file.close();
fs.unlink(destination, () => {});
reject(new Error('Download timeout'));
});
request.on('error', (err) => {
file.close();
fs.unlink(destination, () => {});
this.emit('download-error', { url, error: err, modelId });
reject(err);
});
request.setTimeout(timeout);
file.on('error', (err) => {
fs.unlink(destination, () => {});
reject(err);
});
});
}
async downloadWithRetry(url, destination, options = {}) {
const {
maxRetries = 3,
retryDelay = 1000,
expectedChecksum = null,
modelId = null,
...downloadOptions
} = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.downloadFile(url, destination, {
...downloadOptions,
modelId
});
if (expectedChecksum) {
const isValid = await this.verifyChecksum(destination, expectedChecksum);
if (!isValid) {
fs.unlinkSync(destination);
throw new Error('Checksum verification failed');
}
console.log(`[${this.serviceName}] Checksum verified successfully`);
}
return result;
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
}
}
}
async verifyChecksum(filePath, expectedChecksum) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => {
const fileChecksum = hash.digest('hex');
console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`);
console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`);
resolve(fileChecksum === expectedChecksum);
});
stream.on('error', reject);
});
}
async autoInstall(onProgress) {
const platform = this.getPlatform();
console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`);
try {
switch(platform) {
case 'darwin':
return await this.installMacOS(onProgress);
case 'win32':
return await this.installWindows(onProgress);
case 'linux':
return await this.installLinux();
default:
throw new Error(`Unsupported platform: ${platform}`);
}
} catch (error) {
console.error(`[${this.serviceName}] Auto-installation failed:`, error);
throw error;
}
}
async shutdown(force = false) {
console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`);
const isRunning = await this.isServiceRunning();
if (!isRunning) {
console.log(`[${this.serviceName}] Service not running, nothing to shutdown`);
return true;
}
const platform = this.getPlatform();
try {
switch(platform) {
case 'darwin':
return await this.shutdownMacOS(force);
case 'win32':
return await this.shutdownWindows(force);
case 'linux':
return await this.shutdownLinux(force);
default:
console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`);
return false;
}
} catch (error) {
console.error(`[${this.serviceName}] Error during shutdown:`, error);
return false;
}
}
async initialize() {
if (this.isInitialized) return;
if (this.installState.isInitialized) return;
try {
const homeDir = os.homedir();
@ -51,16 +285,21 @@ class WhisperService extends LocalAIServiceBase {
// Windows에서는 .exe 확장자 필요
const platform = this.getPlatform();
const whisperExecutable = platform === 'win32' ? 'whisper.exe' : 'whisper';
const whisperExecutable = platform === 'win32' ? 'whisper-whisper.exe' : 'whisper';
this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable);
await this.ensureDirectories();
await this.ensureWhisperBinary();
this.isInitialized = true;
this.installState.isInitialized = true;
console.log('[WhisperService] Initialized successfully');
} catch (error) {
console.error('[WhisperService] Initialization failed:', error);
// Emit error event - LocalAIManager가 처리
this.emit('error', {
errorType: 'initialization-failed',
error: error.message
});
throw error;
}
}
@ -71,6 +310,56 @@ class WhisperService extends LocalAIServiceBase {
await fsPromises.mkdir(path.dirname(this.whisperPath), { recursive: true });
}
// local stt session
async getSession(config) {
// check available session
const availableSession = this.sessionPool.find(s => !s.inUse);
if (availableSession) {
availableSession.inUse = true;
await availableSession.reconfigure(config);
return availableSession;
}
// create new session
if (this.activeSessions.size >= this.maxSessions) {
throw new Error('Maximum session limit reached');
}
const session = new WhisperSession(config, this);
await session.initialize();
this.activeSessions.set(session.id, session);
return session;
}
async releaseSession(sessionId) {
const session = this.activeSessions.get(sessionId);
if (session) {
await session.cleanup();
session.inUse = false;
// add to session pool
if (this.sessionPool.length < 2) {
this.sessionPool.push(session);
} else {
// remove session
await session.destroy();
this.activeSessions.delete(sessionId);
}
}
}
//cleanup
async cleanup() {
// cleanup all sessions
for (const session of this.activeSessions.values()) {
await session.destroy();
}
this.activeSessions.clear();
this.sessionPool = [];
}
async ensureWhisperBinary() {
const whisperCliPath = await this.checkCommand('whisper-cli');
if (whisperCliPath) {
@ -99,6 +388,11 @@ class WhisperService extends LocalAIServiceBase {
console.log('[WhisperService] Whisper not found, trying Homebrew installation...');
try {
await this.installViaHomebrew();
// verify installation
const verified = await this.verifyInstallation();
if (!verified.success) {
throw new Error(verified.error);
}
return;
} catch (error) {
console.log('[WhisperService] Homebrew installation failed:', error.message);
@ -106,6 +400,12 @@ class WhisperService extends LocalAIServiceBase {
}
await this.autoInstall();
// verify installation
const verified = await this.verifyInstallation();
if (!verified.success) {
throw new Error(`Whisper installation verification failed: ${verified.error}`);
}
}
async installViaHomebrew() {
@ -132,7 +432,7 @@ class WhisperService extends LocalAIServiceBase {
async ensureModelAvailable(modelId) {
if (!this.isInitialized) {
if (!this.installState.isInitialized) {
console.log('[WhisperService] Service not initialized, initializing now...');
await this.initialize();
}
@ -157,21 +457,60 @@ class WhisperService extends LocalAIServiceBase {
const modelPath = await this.getModelPath(modelId);
const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
this.emit('downloadProgress', { modelId, progress: 0 });
// Emit progress event - LocalAIManager가 처리
this.emit('install-progress', {
model: modelId,
progress: 0
});
await this.downloadWithRetry(modelInfo.url, modelPath, {
expectedChecksum: checksumInfo?.sha256,
modelId, // pass modelId to LocalAIServiceBase for event handling
onProgress: (progress) => {
this.emit('downloadProgress', { modelId, progress });
// Emit progress event - LocalAIManager가 처리
this.emit('install-progress', {
model: modelId,
progress
});
}
});
console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
this.emit('model-download-complete', { modelId });
}
async handleDownloadModel(modelId) {
try {
console.log(`[WhisperService] Handling download for model: ${modelId}`);
if (!this.installState.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.installState.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) {
if (!this.isInitialized || !this.modelsDir) {
if (!this.installState.isInitialized || !this.modelsDir) {
throw new Error('WhisperService is not initialized. Call initialize() first.');
}
return path.join(this.modelsDir, `${modelId}.bin`);
@ -196,7 +535,7 @@ class WhisperService extends LocalAIServiceBase {
createWavHeader(dataSize) {
const header = Buffer.alloc(44);
const sampleRate = 24000;
const sampleRate = 16000;
const numChannels = 1;
const bitsPerSample = 16;
@ -245,7 +584,7 @@ class WhisperService extends LocalAIServiceBase {
}
async getInstalledModels() {
if (!this.isInitialized) {
if (!this.installState.isInitialized) {
console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...');
await this.initialize();
}
@ -274,11 +613,11 @@ class WhisperService extends LocalAIServiceBase {
}
async isServiceRunning() {
return this.isInitialized;
return this.installState.isInitialized;
}
async startService() {
if (!this.isInitialized) {
if (!this.installState.isInitialized) {
await this.initialize();
}
return true;
@ -304,7 +643,7 @@ class WhisperService extends LocalAIServiceBase {
async installWindows() {
console.log('[WhisperService] Installing Whisper on Windows...');
const version = 'v1.7.6';
const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-x64.zip`;
const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-bin-x64.zip`;
const tempFile = path.join(this.tempDir, 'whisper-binary.zip');
try {
@ -382,8 +721,7 @@ class WhisperService extends LocalAIServiceBase {
if (item.isDirectory()) {
const subExecutables = await this.findWhisperExecutables(fullPath);
executables.push(...subExecutables);
} else if (item.isFile() && (item.name === 'whisper.exe' || item.name === 'main.exe')) {
// main.exe도 포함 (일부 빌드에서 whisper 실행파일이 main.exe로 명명됨)
} else if (item.isFile() && (item.name === 'whisper-whisper.exe' || item.name === 'whisper.exe' || item.name === 'main.exe')) {
executables.push(fullPath);
}
}
@ -418,7 +756,7 @@ class WhisperService extends LocalAIServiceBase {
async installLinux() {
console.log('[WhisperService] Installing Whisper on Linux...');
const version = 'v1.7.6';
const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
const tempFile = path.join(this.tempDir, 'whisper-binary.tar.gz');
try {
@ -448,4 +786,92 @@ class WhisperService extends LocalAIServiceBase {
}
}
module.exports = { WhisperService };
// WhisperSession class
class WhisperSession {
constructor(config, service) {
this.id = `session_${Date.now()}_${Math.random()}`;
this.config = config;
this.service = service;
this.process = null;
this.inUse = true;
this.audioBuffer = Buffer.alloc(0);
}
async initialize() {
await this.service.ensureModelAvailable(this.config.model);
this.startProcessingLoop();
}
async reconfigure(config) {
this.config = config;
await this.service.ensureModelAvailable(this.config.model);
}
startProcessingLoop() {
// TODO: 실제 처리 루프 구현
}
async cleanup() {
// 임시 파일 정리
await this.cleanupTempFiles();
}
async cleanupTempFiles() {
// TODO: 임시 파일 정리 구현
}
async destroy() {
if (this.process) {
this.process.kill();
}
// 임시 파일 정리
await this.cleanupTempFiles();
}
}
// verify installation
WhisperService.prototype.verifyInstallation = async function() {
try {
console.log('[WhisperService] Verifying installation...');
// 1. check binary
if (!this.whisperPath) {
return { success: false, error: 'Whisper binary path not set' };
}
try {
await fsPromises.access(this.whisperPath, fs.constants.X_OK);
} catch (error) {
return { success: false, error: 'Whisper binary not executable' };
}
// 2. check version
try {
const { stdout } = await spawnAsync(this.whisperPath, ['--help']);
if (!stdout.includes('whisper')) {
return { success: false, error: 'Invalid whisper binary' };
}
} catch (error) {
return { success: false, error: 'Whisper binary not responding' };
}
// 3. check directories
try {
await fsPromises.access(this.modelsDir, fs.constants.W_OK);
await fsPromises.access(this.tempDir, fs.constants.W_OK);
} catch (error) {
return { success: false, error: 'Required directories not accessible' };
}
console.log('[WhisperService] Installation verified successfully');
return { success: true };
} catch (error) {
console.error('[WhisperService] Verification failed:', error);
return { success: false, error: error.message };
}
};
// Export singleton instance
const whisperService = new WhisperService();
module.exports = whisperService;

View File

@ -1,9 +1,10 @@
const { BrowserWindow, app } = require('electron');
const { BrowserWindow } = require('electron');
const SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService');
const authService = require('../common/services/authService');
const sessionRepository = require('../common/repositories/session');
const sttRepository = require('./stt/repositories');
const internalBridge = require('../../bridge/internalBridge');
class ListenService {
constructor() {
@ -11,8 +12,9 @@ class ListenService {
this.summaryService = new SummaryService();
this.currentSessionId = null;
this.isInitializingSession = false;
this.setupServiceCallbacks();
console.log('[ListenService] Service instance created.');
}
setupServiceCallbacks() {
@ -38,11 +40,60 @@ class ListenService {
}
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
const { windowPool } = require('../../window/windowManager');
const listenWindow = windowPool?.get('listen');
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
initialize() {
this.setupIpcHandlers();
console.log('[ListenService] Initialized and ready.');
}
async handleListenRequest(listenButtonText) {
const { windowPool } = require('../../window/windowManager');
const listenWindow = windowPool.get('listen');
const header = windowPool.get('header');
try {
switch (listenButtonText) {
case 'Listen':
console.log('[ListenService] changeSession to "Listen"');
internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true });
await this.initializeSession();
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send('session-state-changed', { isActive: true });
}
break;
case 'Stop':
console.log('[ListenService] changeSession to "Stop"');
await this.closeSession();
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send('session-state-changed', { isActive: false });
}
break;
case 'Done':
console.log('[ListenService] changeSession to "Done"');
internalBridge.emit('window:requestVisibility', { name: 'listen', visible: false });
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) {
@ -158,8 +209,8 @@ class ListenService {
}
}
async sendAudioContent(data, mimeType) {
return await this.sttService.sendAudioContent(data, mimeType);
async sendMicAudioContent(data, mimeType) {
return await this.sttService.sendMicAudioContent(data, mimeType);
}
async startMacOSAudioCapture() {
@ -183,6 +234,8 @@ class ListenService {
// Close STT sessions
await this.sttService.closeSessions();
await this.stopMacOSAudioCapture();
// End database session
if (this.currentSessionId) {
await sessionRepository.end(this.currentSessionId);
@ -214,88 +267,58 @@ class ListenService {
return this.summaryService.getConversationHistory();
}
setupIpcHandlers() {
const { ipcMain } = require('electron');
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 }) => {
_createHandler(asyncFn, successMessage, errorMessage) {
return async (...args) => {
try {
await this.sendAudioContent(data, mimeType);
return { success: true };
const result = await asyncFn.apply(this, args);
if (successMessage) console.log(successMessage);
// `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로,
// 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다.
// 다른 함수들은 이미 success 객체를 반환합니다.
return result && typeof result.success !== 'undefined' ? result : { success: true };
} catch (e) {
console.error('Error sending user audio:', e);
console.error(errorMessage, e);
return { success: false, error: e.message };
}
});
};
}
ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => {
try {
await this.sttService.sendSystemAudioContent(data, mimeType);
// Send system audio data back to renderer for AEC reference (like macOS does)
this.sendToRenderer('system-audio-data', { data });
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
});
// `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다.
handleSendMicAudioContent = this._createHandler(
this.sendMicAudioContent,
null,
'Error sending user audio:'
);
ipcMain.handle('start-macos-audio', async () => {
handleStartMacosAudio = this._createHandler(
async () => {
if (process.platform !== 'darwin') {
return { success: false, error: 'macOS audio capture only available on macOS' };
}
if (this.sttService.isMacOSAudioRunning?.()) {
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 {
const success = await this.startMacOSAudioCapture();
return { success, error: null };
} catch (error) {
console.error('Error starting macOS audio capture:', error);
return { success: false, error: error.message };
}
});
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');
}
handleUpdateGoogleSearchSetting = this._createHandler(
async (enabled) => {
console.log('Google Search setting updated to:', enabled);
},
null,
'Error updating Google Search setting:'
);
}
module.exports = ListenService;
const listenService = new ListenService();
module.exports = listenService;

View File

@ -1,7 +1,7 @@
const { BrowserWindow } = require('electron');
const { spawn } = require('child_process');
const { createSTT } = require('../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager');
const modelStateService = require('../../common/services/modelStateService');
const COMPLETION_DEBOUNCE_MS = 2000;
@ -34,11 +34,24 @@ class SttService {
}
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
});
// Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
const { windowPool } = require('../../../window/windowManager');
const listenWindow = windowPool?.get('listen');
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
async handleSendSystemAudioContent(data, mimeType) {
try {
await this.sendSystemAudioContent(data, mimeType);
this.sendToRenderer('system-audio-data', { data });
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
}
flushMyCompletion() {
@ -120,7 +133,7 @@ class SttService {
async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
const modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
const modelInfo = await modelStateService.getCurrentModelInfo('stt');
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.');
}
@ -132,6 +145,7 @@ class SttService {
console.log('[SttService] Ignoring message - session already closed');
return;
}
// console.log('[SttService] handleMyMessage', message);
if (this.modelInfo.provider === 'whisper') {
// Whisper STT emits 'transcription' events with different structure
@ -152,10 +166,6 @@ class SttService {
'(NOISE)'
];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern
);
@ -206,6 +216,38 @@ class SttService {
isFinal: false,
timestamp: Date.now(),
});
// Deepgram
} else if (this.modelInfo.provider === 'deepgram') {
const text = message.channel?.alternatives?.[0]?.transcript;
if (!text || text.trim().length === 0) return;
const isFinal = message.is_final;
console.log(`[SttService-Me-Deepgram] Received: isFinal=${isFinal}, text="${text}"`);
if (isFinal) {
// 최종 결과가 도착하면, 현재 진행중인 부분 발화는 비우고
// 최종 텍스트로 debounce를 실행합니다.
this.myCurrentUtterance = '';
this.debounceMyCompletion(text);
} else {
// 부분 결과(interim)인 경우, 화면에 실시간으로 업데이트합니다.
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null;
this.myCurrentUtterance = text;
const continuousText = (this.myCompletionBuffer + ' ' + this.myCurrentUtterance).trim();
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else {
const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -265,9 +307,6 @@ class SttService {
'(NOISE)'
];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern
);
@ -319,6 +358,34 @@ class SttService {
isFinal: false,
timestamp: Date.now(),
});
// Deepgram
} else if (this.modelInfo.provider === 'deepgram') {
const text = message.channel?.alternatives?.[0]?.transcript;
if (!text || text.trim().length === 0) return;
const isFinal = message.is_final;
if (isFinal) {
this.theirCurrentUtterance = '';
this.debounceTheirCompletion(text);
} else {
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = null;
this.theirCurrentUtterance = text;
const continuousText = (this.theirCompletionBuffer + ' ' + this.theirCurrentUtterance).trim();
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else {
const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -388,7 +455,7 @@ class SttService {
return true;
}
async sendAudioContent(data, mimeType) {
async sendMicAudioContent(data, mimeType) {
// const provider = await this.getAiProvider();
// const isGemini = provider === 'gemini';
@ -399,16 +466,20 @@ class SttService {
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
modelInfo = await modelStateService.getCurrentModelInfo('stt');
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
const payload = modelInfo.provider === 'gemini'
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
: data;
let payload;
if (modelInfo.provider === 'gemini') {
payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(data, 'base64');
} else {
payload = data;
}
await this.mySttSession.sendRealtimeInput(payload);
}
@ -420,16 +491,21 @@ class SttService {
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
modelInfo = await modelStateService.getCurrentModelInfo('stt');
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
const payload = modelInfo.provider === 'gemini'
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
: data;
let payload;
if (modelInfo.provider === 'gemini') {
payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(data, 'base64');
} else {
payload = data;
}
await this.theirSttSession.sendRealtimeInput(payload);
}
@ -501,7 +577,7 @@ class SttService {
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
modelInfo = await modelStateService.getCurrentModelInfo('stt');
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
@ -521,9 +597,15 @@ class SttService {
if (this.theirSttSession) {
try {
const payload = modelInfo.provider === 'gemini'
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
: base64Data;
let payload;
if (modelInfo.provider === 'gemini') {
payload = { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(base64Data, 'base64');
} else {
payload = base64Data;
}
await this.theirSttSession.sendRealtimeInput(payload);
} catch (err) {
console.error('Error sending system audio:', err.message);

View File

@ -3,7 +3,7 @@ const { getSystemPrompt } = require('../../common/prompts/promptBuilder.js');
const { createLLM } = require('../../common/ai/factory');
const sessionRepository = require('../../common/repositories/session');
const summaryRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js');
const modelStateService = require('../../common/services/modelStateService');
class SummaryService {
constructor() {
@ -27,11 +27,12 @@ class SummaryService {
}
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
});
const { windowPool } = require('../../../window/windowManager');
const listenWindow = windowPool?.get('listen');
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
addConversationTurn(speaker, text) {
@ -97,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId);
}
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
const modelInfo = await modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.');
}
@ -303,25 +304,20 @@ Keep all points concise and build upon previous analysis if provided.`,
*/
async triggerAnalysisIfNeeded() {
if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`);
console.log(`Triggering analysis - ${this.conversationHistory.length} conversation texts accumulated`);
this.makeOutlineAndRequests(this.conversationHistory)
.then(data => {
if (data) {
console.log('📤 Sending structured data to renderer');
this.sendToRenderer('summary-update', data);
// Notify callback
if (this.onAnalysisComplete) {
this.onAnalysisComplete(data);
}
} else {
console.log('❌ No analysis data returned from non-blocking call');
}
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
const data = await this.makeOutlineAndRequests(this.conversationHistory);
if (data) {
console.log('Sending structured data to renderer');
this.sendToRenderer('summary-update', data);
// Notify callback
if (this.onAnalysisComplete) {
this.onAnalysisComplete(data);
}
} else {
console.log('No analysis data returned');
}
}
}

View File

@ -4,6 +4,10 @@ const authService = require('../common/services/authService');
const settingsRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager');
// New imports for common services
const modelStateService = require('../common/services/modelStateService');
const localAIManager = require('../common/services/localAIManager');
const store = new Store({
name: 'pickle-glass-settings',
defaults: {
@ -19,6 +23,52 @@ const NOTIFICATION_CONFIG = {
RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
};
// New facade functions for model state management
async function getModelSettings() {
try {
const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([
modelStateService.getProviderConfig(),
modelStateService.getAllApiKeys(),
modelStateService.getSelectedModels(),
modelStateService.getAvailableModels('llm'),
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 clearApiKey(provider) {
const success = await modelStateService.handleRemoveApiKey(provider);
return { success };
}
async function setSelectedModel(type, modelId) {
const success = await modelStateService.handleSetSelectedModel(type, modelId);
return { success };
}
// LocalAI facade functions
async function getOllamaStatus() {
return localAIManager.getServiceStatus('ollama');
}
async function ensureOllamaReady() {
const status = await localAIManager.getServiceStatus('ollama');
if (!status.installed || !status.running) {
await localAIManager.startService('ollama');
}
return { success: true };
}
async function shutdownOllama() {
return localAIManager.stopService('ollama');
}
// window targeting system
class WindowNotificationManager {
constructor() {
@ -324,6 +374,7 @@ async function removeApiKey() {
}
});
console.log('[SettingsService] API key removed for all providers');
return { success: true };
} catch (error) {
console.error('[SettingsService] Error removing API key:', error);
@ -373,8 +424,6 @@ function initialize() {
// cleanup
windowNotificationManager.cleanup();
// IPC handlers 제거 (featureBridge로 이동)
console.log('[SettingsService] Initialized and ready.');
}
@ -406,4 +455,13 @@ module.exports = {
removeApiKey,
updateContentProtection,
getAutoUpdateSetting,
setAutoUpdateSetting,
// Model settings facade
getModelSettings,
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,288 @@
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.windowPool = null;
this.allWindowVisibility = true;
}
initialize(windowPool) {
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.');
}
async openShortcutSettingsWindow () {
const keybinds = await this.loadKeybinds();
const shortcutWin = this.windowPool.get('shortcut-settings');
shortcutWin.webContents.send('shortcut:loadShortcuts', keybinds);
globalShortcut.unregisterAll();
internalBridge.emit('window:requestVisibility', { name: 'shortcut-settings', visible: true });
console.log('[ShortcutsService] Shortcut settings window opened.');
return { success: true };
}
async closeShortcutSettingsWindow () {
await this.registerShortcuts();
internalBridge.emit('window:requestVisibility', { name: 'shortcut-settings', visible: false });
console.log('[ShortcutsService] Shortcut settings window closed.');
return { success: true };
}
async handleSaveShortcuts(newKeybinds) {
try {
await this.saveKeybinds(newKeybinds);
await this.closeShortcutSettingsWindow();
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
await this.closeShortcutSettingsWindow();
return { success: false, error: error.message };
}
}
async handleRestoreDefaults() {
const defaults = this.getDefaultKeybinds();
return defaults;
}
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 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.`);
}
async toggleAllWindowsVisibility() {
const targetVisibility = !this.allWindowVisibility;
internalBridge.emit('window:requestToggleAllWindowsVisibility', {
targetVisibility: targetVisibility
});
if (this.allWindowVisibility) {
await this.registerShortcuts(true);
} else {
await this.registerShortcuts();
}
this.allWindowVisibility = !this.allWindowVisibility;
}
async registerShortcuts(registerOnlyToggleVisibility = false) {
if (!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);
if (registerOnlyToggleVisibility) {
if (keybinds.toggleVisibility) {
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
}
console.log('[Shortcuts] registerOnlyToggleVisibility, only toggleVisibility shortcut is registered.');
return;
}
// --- 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, () => internalBridge.emit('window:moveToDisplay', { displayId: 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()) internalBridge.emit('window:moveToEdge', { direction });
});
});
// --- User-configurable shortcuts ---
if (header?.currentHeaderState === 'apikey') {
if (keybinds.toggleVisibility) {
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
}
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();
break;
case 'nextStep':
callback = () => askService.toggleAskButton(true);
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()) internalBridge.emit('window:moveStep', { direction: 'up' }); };
break;
case 'moveDown':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'down' }); };
break;
case 'moveLeft':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'left' }); };
break;
case 'moveRight':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: '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.');
}
}
const shortcutsService = new ShortcutsService();
module.exports = shortcutsService;

View File

@ -13,7 +13,7 @@ if (require('electron-squirrel-startup')) {
const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
const { createWindows } = require('./window/windowManager.js');
const ListenService = require('./features/listen/listenService');
const listenService = require('./features/listen/listenService');
const { initializeFirebase } = require('./features/common/services/firebaseClient');
const databaseInitializer = require('./features/common/services/databaseInitializer');
const authService = require('./features/common/services/authService');
@ -25,21 +25,16 @@ const { EventEmitter } = require('events');
const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./features/common/repositories/session');
const ModelStateService = require('./features/common/services/modelStateService');
const sqliteClient = require('./features/common/services/sqliteClient');
const modelStateService = require('./features/common/services/modelStateService');
const featureBridge = require('./bridge/featureBridge');
const windowBridge = require('./bridge/windowBridge');
// Global variables
const eventBridge = new EventEmitter();
let WEB_PORT = 3000;
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 ////////
const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService;
//////// after_modelStateService ////////
@ -203,13 +198,9 @@ app.whenReady().then(async () => {
await modelStateService.initialize();
//////// after_modelStateService ////////
listenService.setupIpcHandlers();
askService.initialize();
settingsService.initialize();
featureBridge.initialize(); // 추가: featureBridge 초기화
setupGeneralIpcHandlers();
setupOllamaIpcHandlers();
setupWhisperIpcHandlers();
windowBridge.initialize();
setupWebDataHandlers();
// Initialize Ollama models in database
await ollamaModelRepository.initializeDefaultModels();
@ -250,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) => {
// Prevent infinite loop by checking if shutdown is already in progress
if (isShuttingDown) {
@ -274,7 +258,7 @@ app.on('before-quit', async (event) => {
try {
// 1. Stop audio capture first (immediate)
listenService.stopMacOSAudioCapture();
await listenService.closeSession();
console.log('[Shutdown] Audio capture stopped');
// 2. End all active sessions (database operations) - with error handling
@ -330,302 +314,6 @@ app.on('activate', () => {
}
});
function setupWhisperIpcHandlers() {
const { WhisperService } = require('./features/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('./features/common/repositories/user');
const presetRepository = require('./features/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() {
const sessionRepository = require('./features/common/repositories/session');
const sttRepository = require('./features/listen/stt/repositories');
@ -998,75 +686,43 @@ async function startWebStack() {
console.log(`✅ API server started on http://localhost:${apiPort}`);
console.log(`🚀 All services ready:`);
console.log(` Frontend: http://localhost:${frontendPort}`);
console.log(` API: http://localhost:${apiPort}`);
console.log(`🚀 All services ready:
Frontend: http://localhost:${frontendPort}
API: http://localhost:${apiPort}`);
return frontendPort;
}
// Auto-update initialization
async function initAutoUpdater() {
if (process.env.NODE_ENV === 'development') {
console.log('Development environment, skipping auto-updater.');
return;
}
try {
const autoUpdateEnabled = await settingsService.getAutoUpdateSetting();
if (!autoUpdateEnabled) {
console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings');
return;
}
// Skip auto-updater in development mode
if (!app.isPackaged) {
console.log('[AutoUpdater] Skipped in development (app is not packaged)');
return;
}
autoUpdater.setFeedURL({
provider: 'github',
owner: 'pickle-com',
repo: 'glass',
await autoUpdater.checkForUpdates();
autoUpdater.on('update-available', () => {
console.log('Update available!');
autoUpdater.downloadUpdate();
});
// Immediately check for updates & notify
autoUpdater.checkForUpdatesAndNotify()
.catch(err => {
console.error('[AutoUpdater] Error checking for updates:', err);
});
autoUpdater.on('checking-for-update', () => {
console.log('[AutoUpdater] Checking for updates…');
});
autoUpdater.on('update-available', (info) => {
console.log('[AutoUpdater] Update available:', info.version);
});
autoUpdater.on('update-not-available', () => {
console.log('[AutoUpdater] Application is up-to-date');
});
autoUpdater.on('error', (err) => {
console.error('[AutoUpdater] Error while updating:', err);
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`[AutoUpdater] Update downloaded: ${info.version}`);
const dialogOpts = {
autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => {
console.log('Update downloaded:', releaseNotes, releaseName, date, url);
dialog.showMessageBox({
type: 'info',
buttons: ['Install now', 'Install on next launch'],
title: 'Update Available',
message: 'A new version of Glass is ready to be installed.',
defaultId: 0,
cancelId: 1
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
// returnValue.response 0 is for 'Install Now'
if (returnValue.response === 0) {
title: 'Application Update',
message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`,
buttons: ['Restart', 'Later']
}).then(response => {
if (response.response === 0) {
autoUpdater.quitAndInstall();
}
});
});
} catch (e) {
console.error('[AutoUpdater] Failed to initialise:', e);
autoUpdater.on('error', (err) => {
console.error('Error in auto-updater:', err);
});
} catch (err) {
console.error('Error initializing auto-updater:', err);
}
}

View File

@ -2,318 +2,251 @@
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// Ask
ask: {
// sendMessage
sendMessage: (message) => ipcRenderer.invoke('ask:sendMessage', message),
// window
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
forceCloseWindow: (windowName) => ipcRenderer.invoke('force-close-window', windowName),
closeWindowIfEmpty: () => ipcRenderer.invoke('close-ask-window-if-empty'),
// event listener
onGlobalSend: (callback) => ipcRenderer.on('ask-global-send', callback),
onReceiveQuestionFromAssistant: (callback) => ipcRenderer.on('receive-question-from-assistant', callback),
onHideTextInput: (callback) => ipcRenderer.on('hide-text-input', callback),
onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback),
onWindowBlur: (callback) => ipcRenderer.on('window-blur', callback),
onWindowDidShow: (callback) => ipcRenderer.on('window-did-show', callback),
onResponseChunk: (callback) => ipcRenderer.on('ask-response-chunk', callback),
onResponseStreamEnd: (callback) => ipcRenderer.on('ask-response-stream-end', callback),
onScrollResponseUp: (callback) => ipcRenderer.on('scroll-response-up', callback),
onScrollResponseDown: (callback) => ipcRenderer.on('scroll-response-down', callback),
// event listener remove
removeOnGlobalSend: (callback) => ipcRenderer.removeListener('ask-global-send', callback),
removeOnReceiveQuestionFromAssistant: (callback) => ipcRenderer.removeListener('receive-question-from-assistant', callback),
removeOnHideTextInput: (callback) => ipcRenderer.removeListener('hide-text-input', callback),
removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback),
removeOnWindowBlur: (callback) => ipcRenderer.removeListener('window-blur', callback),
removeOnWindowDidShow: (callback) => ipcRenderer.removeListener('window-did-show', callback),
removeOnResponseChunk: (callback) => ipcRenderer.removeListener('ask-response-chunk', callback),
removeOnResponseStreamEnd: (callback) => ipcRenderer.removeListener('ask-response-stream-end', callback),
removeOnScrollResponseUp: (callback) => ipcRenderer.removeListener('scroll-response-up', callback),
removeOnScrollResponseDown: (callback) => ipcRenderer.removeListener('scroll-response-down', callback)
// Platform information for renderer processes
platform: {
isLinux: process.platform === 'linux',
isMacOS: process.platform === 'darwin',
isWindows: process.platform === 'win32',
platform: process.platform
},
// Listen
listen: {
// window
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
// event listener
onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
onSttUpdate: (callback) => ipcRenderer.on('stt-update', callback),
onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback),
// remove event listener
removeOnSessionStateChanged: (callback) => ipcRenderer.removeListener('session-state-changed', callback),
removeOnSttUpdate: (callback) => ipcRenderer.removeListener('stt-update', callback),
removeOnSummaryUpdate: (callback) => ipcRenderer.removeListener('summary-update', callback),
// Ask window
isAskWindowVisible: (windowName) => ipcRenderer.invoke('is-ask-window-visible', windowName),
toggleFeature: (featureName) => ipcRenderer.invoke('toggle-feature', featureName),
sendQuestionToAsk: (question) => ipcRenderer.invoke('send-question-to-ask', question)
},
// Audio
audio: {
// audio capture
sendAudioContent: (options) => ipcRenderer.invoke('send-audio-content', options),
sendSystemAudioContent: (options) => ipcRenderer.invoke('send-system-audio-content', options),
// macOS audio
startMacosAudio: () => ipcRenderer.invoke('start-macos-audio'),
stopMacosAudio: () => ipcRenderer.invoke('stop-macos-audio'),
// screen capture
startScreenCapture: () => ipcRenderer.invoke('start-screen-capture'),
stopScreenCapture: () => ipcRenderer.invoke('stop-screen-capture'),
captureScreenshot: (options) => ipcRenderer.invoke('capture-screenshot', options),
getCurrentScreenshot: () => ipcRenderer.invoke('get-current-screenshot'),
// session
isSessionActive: () => ipcRenderer.invoke('is-session-active'),
// event listener
onChangeListenCaptureState: (callback) => ipcRenderer.on('change-listen-capture-state', callback),
onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
// remove event listener
removeOnChangeListenCaptureState: (callback) => ipcRenderer.removeListener('change-listen-capture-state', callback),
removeOnSystemAudioData: (callback) => ipcRenderer.removeListener('system-audio-data', callback)
},
// Settings
settings: {
// shortcut
saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts),
getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'),
// shortcut editor
closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'),
// event listener
onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback),
// remove event listener
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback)
},
// App
app: {
// quit application
quitApplication: () => ipcRenderer.invoke('quit-application'),
// session
isSessionActive: () => ipcRenderer.invoke('is-session-active'),
// event listener
onClickThroughToggled: (callback) => ipcRenderer.on('click-through-toggled', callback),
// remove event listener
removeOnClickThroughToggled: (callback) => ipcRenderer.removeListener('click-through-toggled', callback),
// remove all listeners
removeAllListeners: (eventName) => ipcRenderer.removeAllListeners(eventName)
},
// API Key Header
apikey: {
// model
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
validateKey: (options) => ipcRenderer.invoke('model:validate-key', options),
setSelectedModel: (options) => ipcRenderer.invoke('model:set-selected-model', options),
areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'),
// Ollama
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
ensureReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
installOllama: () => ipcRenderer.invoke('ollama:install'),
startService: () => ipcRenderer.invoke('ollama:start-service'),
pullModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
// Whisper
downloadModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
// position
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
// authentication
// Common utilities used across multiple components
common: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
quitApplication: () => ipcRenderer.invoke('quit-application'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
// event listener
onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback),
onOllamaInstallComplete: (callback) => ipcRenderer.on('ollama:install-complete', callback),
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
// remove event listener
removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback),
removeOnOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback),
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback),
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
// remove all listeners
removeAllListeners: (eventName) => ipcRenderer.removeAllListeners(eventName)
},
// Controller
controller: {
// user state
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
// model
areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'),
// permission
checkPermissionsCompleted: () => ipcRenderer.invoke('check-permissions-completed'),
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
// window
resizeHeaderWindow: (options) => ipcRenderer.invoke('resize-header-window', options),
// state change
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
// event listener
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback),
onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback),
// remove event listener
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback),
removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback)
},
// Header
header: {
// position
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
// event listener
onSessionStateText: (callback) => ipcRenderer.on('session-state-text', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
// remove event listener
removeOnSessionStateText: (callback) => ipcRenderer.removeListener('session-state-text', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
// animation
sendAnimationFinished: (state) => ipcRenderer.send('header-animation-finished', state),
// settings window
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
showSettingsWindow: (options) => ipcRenderer.send('show-settings-window', options),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// invoke
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args)
},
// Permissions
permissions: {
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
openSystemPreferences: (section) => ipcRenderer.invoke('open-system-preferences', section),
markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed'),
quitApplication: () => ipcRenderer.invoke('quit-application')
},
// Animation
animation: {
// send animation finished
sendAnimationFinished: () => ipcRenderer.send('animation-finished'),
// event listener
onWindowShowAnimation: (callback) => ipcRenderer.on('window-show-animation', callback),
onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback),
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
onListenWindowMoveToCenter: (callback) => ipcRenderer.on('listen-window-move-to-center', callback),
onListenWindowMoveToLeft: (callback) => ipcRenderer.on('listen-window-move-to-left', callback),
// remove event listener
removeOnWindowShowAnimation: (callback) => ipcRenderer.removeListener('window-show-animation', callback),
removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),
removeOnListenWindowMoveToCenter: (callback) => ipcRenderer.removeListener('listen-window-move-to-center', callback),
removeOnListenWindowMoveToLeft: (callback) => ipcRenderer.removeListener('listen-window-move-to-left', callback)
},
feature: {
// ask
submitAsk: (query) => ipcRenderer.invoke('feature:ask', query),
onAskProgress: (callback) => ipcRenderer.on('feature:ask:progress', (e, p) => callback(p)),
settings: {
// invoke methods
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
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'),
getPresets: () => ipcRenderer.invoke('settings:getPresets'),
getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'),
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
getWhisperInstalledModels: () => ipcRenderer.invoke('whisper:get-installed-models'),
ollamaEnsureReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
getAutoUpdate: () => ipcRenderer.invoke('settings:get-auto-update'),
setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
removeApiKey: (provider) => ipcRenderer.invoke('model:remove-api-key', provider),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
openLoginPage: () => ipcRenderer.invoke('open-login-page'),
toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
ollamaShutdown: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
// on methods (listeners)
// User state listener (used by multiple components)
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),
},
// send methods
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window')
// UI Component specific namespaces
// src/ui/app/ApiKeyHeader.js
apiKeyHeader: {
// Model & Provider Management
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
// LocalAI 통합 API
getLocalAIStatus: (service) => ipcRenderer.invoke('localai:get-status', service),
installLocalAI: (service, options) => ipcRenderer.invoke('localai:install', { service, options }),
startLocalAIService: (service) => ipcRenderer.invoke('localai:start-service', service),
stopLocalAIService: (service) => ipcRenderer.invoke('localai:stop-service', service),
installLocalAIModel: (service, modelId, options) => ipcRenderer.invoke('localai:install-model', { service, modelId, options }),
getInstalledModels: (service) => ipcRenderer.invoke('localai:get-installed-models', service),
// Legacy support (호환성 위해 유지)
getOllamaStatus: () => ipcRenderer.invoke('localai:get-status', 'ollama'),
getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
installOllama: () => ipcRenderer.invoke('localai:install', { service: 'ollama' }),
startOllamaService: () => ipcRenderer.invoke('localai:start-service', 'ollama'),
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
// LocalAI 통합 이벤트 리스너
onLocalAIProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
removeOnLocalAIProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
onLocalAIComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
removeOnLocalAIComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback),
onLocalAIError: (callback) => ipcRenderer.on('localai:error-notification', callback),
removeOnLocalAIError: (callback) => ipcRenderer.removeListener('localai:error-notification', callback),
onLocalAIModelReady: (callback) => ipcRenderer.on('localai:model-ready', callback),
removeOnLocalAIModelReady: (callback) => ipcRenderer.removeListener('localai:model-ready', callback),
// Remove all listeners (for cleanup)
removeAllListeners: () => {
// LocalAI 통합 이벤트
ipcRenderer.removeAllListeners('localai:install-progress');
ipcRenderer.removeAllListeners('localai:installation-complete');
ipcRenderer.removeAllListeners('localai:error-notification');
ipcRenderer.removeAllListeners('localai:model-ready');
ipcRenderer.removeAllListeners('localai:service-status-changed');
}
},
// window
window: {
// window
hide: () => ipcRenderer.send('window:hide'),
onFocusChange: (callback) => ipcRenderer.on('window:focus-change', (e, f) => callback(f)),
// settings window
showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// src/ui/app/HeaderController.js
headerController: {
// State Management
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
reInitializeModelState: () => ipcRenderer.invoke('model:re-initialize-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'),
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
openLoginPage: () => ipcRenderer.invoke('open-login-page'),
showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// Generic invoke (for dynamic channel names)
// invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
sendListenButtonClick: (listenButtonText) => ipcRenderer.invoke('listen:changeSession', listenButtonText),
sendAskButtonClick: () => ipcRenderer.invoke('ask:toggleAskButton'),
sendToggleAllWindowsVisibility: () => ipcRenderer.invoke('shortcut:toggleAllWindowsVisibility'),
// 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),
markKeychainCompleted: () => ipcRenderer.invoke('mark-keychain-completed'),
checkKeychainCompleted: (uid) => ipcRenderer.invoke('check-keychain-completed', uid),
initializeEncryptionKey: () => ipcRenderer.invoke('initialize-encryption-key') // New for keychain
},
// 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: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, 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: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, 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'),
ollamaShutdown: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
// event listener
// 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('settings:getCurrentShortcuts'),
openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'),
// 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),
@ -322,11 +255,52 @@ contextBridge.exposeInMainWorld('api', {
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),
// 통합 LocalAI 이벤트 사용
onLocalAIInstallProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
removeOnLocalAIInstallProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
onLocalAIInstallationComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
removeOnLocalAIInstallationComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback)
},
// send
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window')
// src/ui/settings/ShortCutSettingsView.js
shortcutSettingsView: {
// Shortcut Management
saveShortcuts: (shortcuts) => ipcRenderer.invoke('shortcut:saveShortcuts', shortcuts),
getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'),
closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'),
// Listeners
onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback),
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback)
},
// src/ui/app/content.html inline scripts
content: {
// Listeners
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', 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('listen:isSessionActive'),
// 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)
}
});

File diff suppressed because it is too large Load Diff

View File

@ -1,18 +1,20 @@
import './MainHeader.js';
import './ApiKeyHeader.js';
import './PermissionHeader.js';
import './WelcomeHeader.js';
class HeaderTransitionManager {
constructor() {
this.headerContainer = document.getElementById('header-container');
this.currentHeaderType = null; // 'apikey' | 'main' | 'permission'
this.currentHeaderType = null; // 'welcome' | 'apikey' | 'main' | 'permission'
this.welcomeHeader = null;
this.apiKeyHeader = null;
this.mainHeader = null;
this.permissionHeader = null;
/**
* only one header window is allowed
* @param {'apikey'|'main'|'permission'} type
* @param {'welcome'|'apikey'|'main'|'permission'} type
*/
this.ensureHeader = (type) => {
console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
@ -23,18 +25,39 @@ class HeaderTransitionManager {
this.headerContainer.innerHTML = '';
this.welcomeHeader = null;
this.apiKeyHeader = null;
this.mainHeader = null;
this.permissionHeader = null;
// Create new header element
if (type === 'apikey') {
if (type === 'welcome') {
this.welcomeHeader = document.createElement('welcome-header');
this.welcomeHeader.loginCallback = () => this.handleLoginOption();
this.welcomeHeader.apiKeyCallback = () => this.handleApiKeyOption();
this.headerContainer.appendChild(this.welcomeHeader);
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
} else if (type === 'apikey') {
this.apiKeyHeader = document.createElement('apikey-header');
this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
this.apiKeyHeader.addEventListener('request-resize', e => {
this._resizeForApiKey(e.detail.height);
});
this.headerContainer.appendChild(this.apiKeyHeader);
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
} else if (type === 'permission') {
this.permissionHeader = document.createElement('permission-setup');
this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
this.permissionHeader.addEventListener('request-resize', e => {
this._resizeForPermissionHeader(e.detail.height);
});
this.permissionHeader.continueCallback = async () => {
if (window.api && window.api.headerController) {
console.log('[HeaderController] Re-initializing model state after permission grant...');
await window.api.headerController.reInitializeModelState();
}
this.transitionToMainHeader();
};
this.headerContainer.appendChild(this.permissionHeader);
} else {
this.mainHeader = document.createElement('main-header');
@ -48,72 +71,105 @@ class HeaderTransitionManager {
console.log('[HeaderController] Manager initialized');
// WelcomeHeader 콜백 메서드들
this.handleLoginOption = this.handleLoginOption.bind(this);
this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
this._bootstrap();
if (window.api && window.api.controller) {
window.api.controller.onUserStateChanged((event, userState) => {
if (window.api) {
window.api.headerController.onUserStateChanged((event, userState) => {
console.log('[HeaderController] Received user state change:', userState);
this.handleStateUpdate(userState);
});
window.api.controller.onAuthFailed((event, { message }) => {
window.api.headerController.onAuthFailed((event, { message }) => {
console.error('[HeaderController] Received auth failure from main process:', message);
if (this.apiKeyHeader) {
this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.';
this.apiKeyHeader.isLoading = false;
}
});
window.api.controller.onForceShowApiKeyHeader(async () => {
window.api.headerController.onForceShowApiKeyHeader(async () => {
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
await this._resizeForApiKey();
this.ensureHeader('apikey');
});
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
if (!isConfigured) {
await this._resizeForWelcome();
this.ensureHeader('welcome');
} else {
await this._resizeForApiKey();
this.ensureHeader('apikey');
}
});
}
}
notifyHeaderState(stateOverride) {
const state = stateOverride || this.currentHeaderType || 'apikey';
if (window.api && window.api.controller) {
window.api.controller.sendHeaderStateChanged(state);
if (window.api) {
window.api.headerController.sendHeaderStateChanged(state);
}
}
async _bootstrap() {
// The initial state will be sent by the main process via 'user-state-changed'
// We just need to request it.
if (window.api && window.api.controller) {
const userState = await window.api.controller.getCurrentUser();
if (window.api) {
const userState = await window.api.common.getCurrentUser();
console.log('[HeaderController] Bootstrapping with initial user state:', userState);
this.handleStateUpdate(userState);
} else {
// Fallback for non-electron environment (testing/web)
this.ensureHeader('apikey');
this.ensureHeader('welcome');
}
}
//////// after_modelStateService ////////
async handleStateUpdate(userState) {
if (!window.api || !window.api.controller) return;
const isConfigured = await window.api.controller.areProvidersConfigured();
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
if (isConfigured) {
const { isLoggedIn } = userState;
if (isLoggedIn) {
const permissionResult = await this.checkPermissions();
if (permissionResult.success) {
this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
}
} else {
// If providers are configured, always check permissions regardless of login state.
const permissionResult = await this.checkPermissions();
if (permissionResult.success) {
this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
}
} else {
await this._resizeForApiKey();
this.ensureHeader('apikey');
// If no providers are configured, show the welcome header to prompt for setup.
await this._resizeForWelcome();
this.ensureHeader('welcome');
}
}
// WelcomeHeader 콜백 메서드들
async handleLoginOption() {
console.log('[HeaderController] Login option selected');
if (window.api) {
await window.api.common.startFirebaseAuth();
}
}
async handleApiKeyOption() {
console.log('[HeaderController] API key option selected');
await this._resizeForApiKey(400);
this.ensureHeader('apikey');
// ApiKeyHeader에 뒤로가기 콜백 설정
if (this.apiKeyHeader) {
this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
}
}
async transitionToWelcomeHeader() {
if (this.currentHeaderType === 'welcome') {
return this._resizeForWelcome();
}
await this._resizeForWelcome();
this.ensureHeader('welcome');
}
//////// after_modelStateService ////////
async transitionToPermissionHeader() {
@ -124,9 +180,9 @@ class HeaderTransitionManager {
}
// Check if permissions were previously completed
if (window.api && window.api.controller) {
if (window.api) {
try {
const permissionsCompleted = await window.api.controller.checkPermissionsCompleted();
const permissionsCompleted = await window.api.headerController.checkPermissionsCompleted();
if (permissionsCompleted) {
console.log('[HeaderController] Permissions were previously completed, checking current status...');
@ -145,7 +201,19 @@ class HeaderTransitionManager {
}
}
await this._resizeForPermissionHeader();
let initialHeight = 220;
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
if (userState.mode === 'firebase') {
initialHeight = 280;
}
} catch (e) {
console.error('Could not get user state for resize', e);
}
}
await this._resizeForPermissionHeader(initialHeight);
this.ensureHeader('permission');
}
@ -158,31 +226,39 @@ class HeaderTransitionManager {
this.ensureHeader('main');
}
_resizeForMain() {
if (!window.api || !window.api.controller) return;
return window.api.controller.resizeHeaderWindow({ width: 353, height: 47 })
async _resizeForMain() {
if (!window.api) return;
console.log('[HeaderController] _resizeForMain: Resizing window to 353x47');
return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }).catch(() => {});
}
async _resizeForApiKey(height = 370) {
if (!window.api) return;
console.log(`[HeaderController] _resizeForApiKey: Resizing window to 456x${height}`);
return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
}
async _resizeForPermissionHeader(height) {
if (!window.api) return;
const finalHeight = height || 220;
return window.api.headerController.resizeHeaderWindow({ width: 285, height: finalHeight })
.catch(() => {});
}
async _resizeForApiKey() {
if (!window.api || !window.api.controller) return;
return window.api.controller.resizeHeaderWindow({ width: 350, height: 300 })
.catch(() => {});
}
async _resizeForPermissionHeader() {
if (!window.api || !window.api.controller) return;
return window.api.controller.resizeHeaderWindow({ 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(() => {});
}
async checkPermissions() {
if (!window.api || !window.api.controller) {
if (!window.api) {
return { success: true };
}
try {
const permissions = await window.api.controller.checkSystemPermissions();
const permissions = await window.api.headerController.checkSystemPermissions();
console.log('[HeaderController] Current permissions:', permissions);
if (!permissions.needsSetup) {

View File

@ -2,10 +2,9 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class MainHeader extends LitElement {
static properties = {
// isSessionActive: { type: Boolean, state: true },
isTogglingSession: { type: Boolean, state: true },
actionText: { type: String, state: true },
shortcuts: { type: Object, state: true },
listenSessionStatus: { type: String, state: true },
};
static styles = css`
@ -348,9 +347,8 @@ export class MainHeader extends LitElement {
this.isAnimating = false;
this.hasSlidIn = false;
this.settingsHideTimer = null;
// this.isSessionActive = false;
this.isTogglingSession = false;
this.actionText = 'Listen';
this.listenSessionStatus = 'beforeSession';
this.animationEndTimer = null;
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this);
@ -359,11 +357,19 @@ export class MainHeader extends LitElement {
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) {
e.preventDefault();
if (!window.api || !window.api.header) return;
const initialPosition = await window.api.header.getHeaderPosition();
const initialPosition = await window.api.mainHeader.getHeaderPosition();
this.dragState = {
initialMouseX: e.screenX,
@ -390,9 +396,7 @@ export class MainHeader extends LitElement {
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
if (window.api && window.api.header) {
window.api.header.moveHeaderTo(newWindowX, newWindowY);
}
window.api.mainHeader.moveHeaderTo(newWindowX, newWindowY);
}
handleMouseUp(e) {
@ -448,12 +452,12 @@ export class MainHeader extends LitElement {
if (this.classList.contains('hiding')) {
this.classList.add('hidden');
if (window.api && window.api.header) {
window.api.header.sendAnimationFinished('hidden');
if (window.api) {
window.api.mainHeader.sendHeaderAnimationFinished('hidden');
}
} else if (this.classList.contains('showing')) {
if (window.api && window.api.header) {
window.api.header.sendAnimationFinished('visible');
if (window.api) {
window.api.mainHeader.sendHeaderAnimationFinished('visible');
}
}
}
@ -467,24 +471,27 @@ export class MainHeader extends LitElement {
super.connectedCallback();
this.addEventListener('animationend', this.handleAnimationEnd);
if (window.api && window.api.header) {
this._sessionStateTextListener = (event, text) => {
this.actionText = text;
this.isTogglingSession = false;
if (window.api) {
this._sessionStateTextListener = (event, { success }) => {
if (success) {
this.listenSessionStatus = ({
beforeSession: 'inSession',
inSession: 'afterSession',
afterSession: 'beforeSession',
})[this.listenSessionStatus] || 'beforeSession';
} else {
this.listenSessionStatus = 'beforeSession';
}
this.isTogglingSession = false; // ✨ 로딩 상태만 해제
};
window.api.header.onSessionStateText(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) => {
console.log('[MainHeader] Received updated shortcuts:', keybinds);
this.shortcuts = keybinds;
};
window.api.header.onShortcutsUpdated(this._shortcutListener);
window.api.mainHeader.onShortcutsUpdated(this._shortcutListener);
}
}
@ -497,51 +504,30 @@ export class MainHeader extends LitElement {
this.animationEndTimer = null;
}
if (window.api && window.api.header) {
if (window.api) {
if (this._sessionStateTextListener) {
window.api.header.removeOnSessionStateText(this._sessionStateTextListener);
window.api.mainHeader.removeOnListenChangeSessionResult(this._sessionStateTextListener);
}
// if (this._sessionStateListener) {
// ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
// }
if (this._shortcutListener) {
window.api.header.removeOnShortcutsUpdated(this._shortcutListener);
window.api.mainHeader.removeOnShortcutsUpdated(this._shortcutListener);
}
}
}
invoke(channel, ...args) {
if (this.wasJustDragged) return;
if (window.api && window.api.header) {
window.api.header.invoke(channel, ...args);
}
// return Promise.resolve();
}
showSettingsWindow(element) {
if (this.wasJustDragged) return;
if (window.api && window.api.header) {
if (window.api) {
console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`);
window.api.header.cancelHideSettingsWindow();
window.api.mainHeader.showSettingsWindow();
if (element) {
const { left, top, width, height } = element.getBoundingClientRect();
window.api.header.showSettingsWindow({
x: left,
y: top,
width,
height,
});
}
}
}
hideSettingsWindow() {
if (this.wasJustDragged) return;
if (window.api && window.api.header) {
if (window.api) {
console.log(`[MainHeader] hideSettingsWindow called at ${Date.now()}`);
window.api.header.hideSettingsWindow();
window.api.mainHeader.hideSettingsWindow();
}
}
@ -554,15 +540,40 @@ export class MainHeader extends LitElement {
this.isTogglingSession = true;
try {
const channel = 'toggle-feature';
const args = ['listen'];
await this.invoke(channel, ...args);
const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
if (window.api) {
await window.api.mainHeader.sendListenButtonClick(listenButtonText);
}
} catch (error) {
console.error('IPC invoke for session toggle failed:', error);
console.error('IPC invoke for session change failed:', error);
this.isTogglingSession = false;
}
}
async _handleAskClick() {
if (this.wasJustDragged) return;
try {
if (window.api) {
await window.api.mainHeader.sendAskButtonClick();
}
} catch (error) {
console.error('IPC invoke for ask button failed:', error);
}
}
async _handleToggleAllWindowsVisibility() {
if (this.wasJustDragged) return;
try {
if (window.api) {
await window.api.mainHeader.sendToggleAllWindowsVisibility();
}
} catch (error) {
console.error('IPC invoke for all windows visibility button failed:', error);
}
}
renderShortcut(accelerator) {
if (!accelerator) return html``;
@ -588,11 +599,13 @@ export class MainHeader extends LitElement {
}
render() {
const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
const buttonClasses = {
active: this.actionText === 'Stop',
done: this.actionText === 'Done',
active: listenButtonText === 'Stop',
done: listenButtonText === 'Done',
};
const showStopIcon = this.actionText === 'Stop' || this.actionText === 'Done';
const showStopIcon = listenButtonText === 'Stop' || listenButtonText === 'Done';
return html`
<div class="header" @mousedown=${this.handleMouseDown}>
@ -609,7 +622,7 @@ export class MainHeader extends LitElement {
`
: html`
<div class="action-text">
<div class="action-text-content">${this.actionText}</div>
<div class="action-text-content">${listenButtonText}</div>
</div>
<div class="listen-icon">
${showStopIcon
@ -629,7 +642,7 @@ export class MainHeader extends LitElement {
`}
</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-content">Ask</div>
</div>
@ -638,7 +651,7 @@ export class MainHeader extends LitElement {
</div>
</div>
<div class="header-actions" @click=${() => this.invoke('toggle-all-windows-visibility')}>
<div class="header-actions" @click=${() => this._handleToggleAllWindowsVisibility()}>
<div class="action-text">
<div class="action-text-content">Show/Hide</div>
</div>

View File

@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement {
.container {
-webkit-app-region: drag;
width: 285px;
height: 220px;
/* height is now set dynamically */
padding: 18px 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 16px;
@ -103,6 +103,12 @@ export class PermissionHeader extends LitElement {
margin-top: auto;
}
.form-content.all-granted {
flex-grow: 1;
justify-content: center;
margin-top: 0;
}
.subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
@ -258,24 +264,60 @@ export class PermissionHeader extends LitElement {
static properties = {
microphoneGranted: { type: String },
screenGranted: { type: String },
keychainGranted: { type: String },
isChecking: { type: String },
continueCallback: { type: Function }
continueCallback: { type: Function },
userMode: { type: String }, // 'local' or 'firebase'
};
constructor() {
super();
this.microphoneGranted = 'unknown';
this.screenGranted = 'unknown';
this.keychainGranted = 'unknown';
this.isChecking = false;
this.continueCallback = null;
this.userMode = 'local'; // Default to local
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('userMode')) {
const newHeight = this.userMode === 'firebase' ? 280 : 220;
console.log(`[PermissionHeader] User mode changed to ${this.userMode}, requesting resize to ${newHeight}px`);
this.dispatchEvent(new CustomEvent('request-resize', {
detail: { height: newHeight },
bubbles: true,
composed: true
}));
}
}
async connectedCallback() {
super.connectedCallback();
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
this.userMode = userState.mode;
} catch (e) {
console.error('[PermissionHeader] Failed to get user state', e);
this.userMode = 'local'; // Fallback to local
}
}
await this.checkPermissions();
// Set up periodic permission check
this.permissionCheckInterval = setInterval(() => {
this.permissionCheckInterval = setInterval(async () => {
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
this.userMode = userState.mode;
} catch (e) {
this.userMode = 'local';
}
}
this.checkPermissions();
}, 1000);
}
@ -288,29 +330,35 @@ export class PermissionHeader extends LitElement {
}
async checkPermissions() {
if (!window.api || !window.api.permissions || this.isChecking) return;
if (!window.api || this.isChecking) return;
this.isChecking = true;
try {
const permissions = await window.api.permissions.checkSystemPermissions();
const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Permission check result:', permissions);
const prevMic = this.microphoneGranted;
const prevScreen = this.screenGranted;
const prevKeychain = this.keychainGranted;
this.microphoneGranted = permissions.microphone;
this.screenGranted = permissions.screen;
this.keychainGranted = permissions.keychain;
// if permissions changed == UI update
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted || prevKeychain !== this.keychainGranted) {
console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate();
}
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
// if all permissions granted == automatically continue
if (this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' &&
keychainOk &&
this.continueCallback) {
console.log('[PermissionHeader] All permissions granted, proceeding automatically');
setTimeout(() => this.handleContinue(), 500);
@ -323,12 +371,12 @@ export class PermissionHeader extends LitElement {
}
async handleMicrophoneClick() {
if (!window.api || !window.api.permissions || this.microphoneGranted === 'granted') return;
if (!window.api || this.microphoneGranted === 'granted') return;
console.log('[PermissionHeader] Requesting microphone permission...');
try {
const result = await window.api.permissions.checkSystemPermissions();
const result = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Microphone permission result:', result);
if (result.microphone === 'granted') {
@ -338,7 +386,7 @@ export class PermissionHeader extends LitElement {
}
if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
const res = await window.api.permissions.requestMicrophonePermission();
const res = await window.api.permissionHeader.requestMicrophonePermission();
if (res.status === 'granted' || res.success === true) {
this.microphoneGranted = 'granted';
this.requestUpdate();
@ -355,12 +403,12 @@ export class PermissionHeader extends LitElement {
}
async handleScreenClick() {
if (!window.api || !window.api.permissions || this.screenGranted === 'granted') return;
if (!window.api || this.screenGranted === 'granted') return;
console.log('[PermissionHeader] Checking screen recording permission...');
try {
const permissions = await window.api.permissions.checkSystemPermissions();
const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Screen permission check result:', permissions);
if (permissions.screen === 'granted') {
@ -370,7 +418,7 @@ export class PermissionHeader extends LitElement {
}
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
console.log('[PermissionHeader] Opening screen recording preferences...');
await window.api.permissions.openSystemPreferences('screen-recording');
await window.api.permissionHeader.openSystemPreferences('screen-recording');
}
// Check permissions again after a delay
@ -381,17 +429,39 @@ export class PermissionHeader extends LitElement {
}
}
async handleKeychainClick() {
if (!window.api || this.keychainGranted === 'granted') return;
console.log('[PermissionHeader] Requesting keychain permission...');
try {
// Trigger initializeKey to prompt for keychain access
// Assuming encryptionService is accessible or via API
await window.api.permissionHeader.initializeEncryptionKey(); // New IPC handler needed
// After success, update status
this.keychainGranted = 'granted';
this.requestUpdate();
} catch (error) {
console.error('[PermissionHeader] Error requesting keychain permission:', error);
}
}
async handleContinue() {
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
if (this.continueCallback &&
this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted') {
this.screenGranted === 'granted' &&
keychainOk) {
// Mark permissions as completed
if (window.api && window.api.permissions) {
if (window.api && isKeychainRequired) {
try {
await window.api.permissions.markPermissionsCompleted();
console.log('[PermissionHeader] Marked permissions as completed');
await window.api.permissionHeader.markKeychainCompleted();
console.log('[PermissionHeader] Marked keychain as completed');
} catch (error) {
console.error('[PermissionHeader] Error marking permissions as completed:', error);
console.error('[PermissionHeader] Error marking keychain as completed:', error);
}
}
@ -401,16 +471,19 @@ export class PermissionHeader extends LitElement {
handleClose() {
console.log('Close button clicked');
if (window.api && window.api.permissions) {
window.api.permissions.quitApplication();
if (window.api) {
window.api.common.quitApplication();
}
}
render() {
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
const isKeychainRequired = this.userMode === 'firebase';
const containerHeight = isKeychainRequired ? 280 : 220;
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && keychainOk;
return html`
<div class="container">
<div class="container" style="height: ${containerHeight}px">
<button class="close-button" @click=${this.handleClose} title="Close application">
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
@ -418,65 +491,92 @@ export class PermissionHeader extends LitElement {
</button>
<h1 class="title">Permission Setup Required</h1>
<div class="form-content">
<div class="subtitle">Grant access to microphone and screen recording to continue</div>
<div class="permission-status">
<div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
${this.microphoneGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Microphone </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
<span>Microphone</span>
`}
</div>
<div class="form-content ${allGranted ? 'all-granted' : ''}">
${!allGranted ? html`
<div class="subtitle">Grant access to microphone, screen recording${isKeychainRequired ? ' and keychain' : ''} to continue</div>
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
${this.screenGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Screen </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
<span>Screen Recording</span>
`}
</div>
</div>
<div class="permission-status">
<div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
${this.microphoneGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Microphone </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
</svg>
<span>Microphone</span>
`}
</div>
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
${this.screenGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Screen </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
</svg>
<span>Screen Recording</span>
`}
</div>
${isKeychainRequired ? html`
<div class="permission-item ${this.keychainGranted === 'granted' ? 'granted' : ''}">
${this.keychainGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Data Encryption </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.744 5.668l-1.649 1.652c-.63.63-1.706.19-1.706-.742V12.18a.75.75 0 00-1.5 0v2.696c0 .932-1.075 1.372-1.706.742l-1.649-1.652A6 6 0 112 8zm-4 0a.75.75 0 00.75-.75A3.75 3.75 0 018.25 4a.75.75 0 000 1.5 2.25 2.25 0 012.25 2.25.75.75 0 00.75.75z" clip-rule="evenodd" />
</svg>
<span>Data Encryption</span>
`}
</div>
` : ''}
</div>
${this.microphoneGranted !== 'granted' ? html`
<button
class="action-button"
@click=${this.handleMicrophoneClick}
?disabled=${this.microphoneGranted === 'granted'}
>
Grant Microphone Access
${this.microphoneGranted === 'granted' ? 'Microphone Access Granted' : 'Grant Microphone Access'}
</button>
` : ''}
${this.screenGranted !== 'granted' ? html`
<button
class="action-button"
@click=${this.handleScreenClick}
?disabled=${this.screenGranted === 'granted'}
>
Grant Screen Recording Access
${this.screenGranted === 'granted' ? 'Screen Recording Granted' : 'Grant Screen Recording Access'}
</button>
` : ''}
${allGranted ? html`
${isKeychainRequired ? html`
<button
class="action-button"
@click=${this.handleKeychainClick}
?disabled=${this.keychainGranted === 'granted'}
>
${this.keychainGranted === 'granted' ? 'Encryption Enabled' : 'Enable Encryption'}
</button>
<div class="subtitle" style="visibility: ${this.keychainGranted === 'granted' ? 'hidden' : 'visible'}">
Stores the key to encrypt your data. Press "<b>Always Allow</b>" to continue.
</div>
` : ''}
` : html`
<button
class="continue-button"
@click=${this.handleContinue}
>
Continue to Pickle Glass
</button>
` : ''}
`}
</div>
</div>
`;

View File

@ -74,30 +74,21 @@ export class PickleGlassApp extends LitElement {
connectedCallback() {
super.connectedCallback();
if (window.api && window.api.app) {
window.api.app.onClickThroughToggled((_, isEnabled) => {
if (window.api) {
window.api.pickleGlassApp.onClickThroughToggled((_, isEnabled) => {
this._isClickThrough = isEnabled;
});
// window.api.app.onStartListeningSession(() => {
// console.log('Received start-listening-session command, calling handleListenClick.');
// this.handleListenClick();
// });
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (window.api && window.api.app) {
window.api.app.removeAllListeners('click-through-toggled');
// window.api.app.removeAllListeners('start-listening-session');
if (window.api) {
window.api.pickleGlassApp.removeAllClickThroughListeners();
}
}
updated(changedProperties) {
// if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) {
// this.requestWindowResize();
// }
if (changedProperties.has('currentView')) {
const viewContainer = this.shadowRoot?.querySelector('.view-container');
if (viewContainer) {
@ -126,39 +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() {
if (window.api && window.api.app) {
await window.api.app.quitApplication();
if (window.api) {
await window.api.common.quitApplication();
}
}

236
src/ui/app/WelcomeHeader.js Normal file
View File

@ -0,0 +1,236 @@
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;
-webkit-app-region: no-drag;
}
`;
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.api?.common) {
window.api.common.quitApplication();
}
}
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() {
console.log('🔊 openPrivacyPolicy WelcomeHeader');
if (window.api?.common) {
window.api.common.openExternal('https://pickle.com/privacy-policy');
}
}
}
customElements.define('welcome-header', WelcomeHeader);

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<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>
<style>
:root {
@ -98,133 +98,6 @@
contain: layout style paint;
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
}
.window-sliding-down {
animation: slideDownFromHeader 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
will-change: transform, opacity;
transform-style: preserve-3d;
}
.window-sliding-up {
animation: slideUpToHeader 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
will-change: transform, opacity;
transform-style: preserve-3d;
}
.window-hidden {
opacity: 0;
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
pointer-events: none;
will-change: auto;
contain: layout style paint;
}
.listen-window-moving {
transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
will-change: transform;
}
.listen-window-center {
transform: translate3d(0, 0, 0);
}
.listen-window-left {
transform: translate3d(-110px, 0, 0);
}
@keyframes slideDownFromHeader {
0% {
opacity: 0;
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
}
25% {
opacity: 0.4;
transform: translate3d(0, -10px, 0) scale3d(0.98, 0.98, 1);
}
50% {
opacity: 0.7;
transform: translate3d(0, -3px, 0) scale3d(1.01, 1.01, 1);
}
75% {
opacity: 0.9;
transform: translate3d(0, -0.5px, 0) scale3d(1.005, 1.005, 1);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
}
.settings-window-show {
animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
transform-origin: 85% 0%;
will-change: transform, opacity;
transform-style: preserve-3d;
}
.settings-window-hide {
animation: settingsCollapseToButton 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
transform-origin: 85% 0%;
will-change: transform, opacity;
transform-style: preserve-3d;
}
@keyframes settingsPopFromButton {
0% {
opacity: 0;
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
}
40% {
opacity: 0.8;
transform: translate3d(0, -2px, 0) scale3d(1.05, 1.05, 1);
}
70% {
opacity: 0.95;
transform: translate3d(0, 0, 0) scale3d(1.02, 1.02, 1);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
}
@keyframes settingsCollapseToButton {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
30% {
opacity: 0.8;
transform: translate3d(0, -1px, 0) scale3d(0.9, 0.9, 1);
}
70% {
opacity: 0.3;
transform: translate3d(0, -5px, 0) scale3d(0.7, 0.7, 1);
}
100% {
opacity: 0;
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
}
}
@keyframes slideUpToHeader {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
30% {
opacity: 0.6;
transform: translate3d(0, -6px, 0) scale3d(0.98, 0.98, 1);
}
65% {
opacity: 0.2;
transform: translate3d(0, -14px, 0) scale3d(0.95, 0.95, 1);
}
100% {
opacity: 0;
transform: translate3d(0, -18px, 0) scale3d(0.93, 0.93, 1);
}
}
</style>
</head>
<body>
@ -237,65 +110,7 @@
<script>
window.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('pickle-glass');
if (window.api && window.api.animation) {
// --- REFACTORED: Event-driven animation handling ---
app.addEventListener('animationend', (event) => {
// 숨김 애니메이션이 끝나면 main 프로세스에 알려 창을 실제로 숨깁니다.
if (event.animationName === 'slideUpToHeader' || event.animationName === 'settingsCollapseToButton') {
console.log(`Animation finished: ${event.animationName}. Notifying main process.`);
window.api.animation.sendAnimationFinished();
// 완료 후 애니메이션 클래스 정리
app.classList.remove('window-sliding-up', 'settings-window-hide');
app.classList.add('window-hidden');
} else if (event.animationName === 'slideDownFromHeader' || event.animationName === 'settingsPopFromButton') {
// 보이기 애니메이션 완료 후 클래스 정리
app.classList.remove('window-sliding-down', 'settings-window-show');
}
});
window.api.animation.onWindowShowAnimation(() => {
console.log('Starting window show animation');
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
app.classList.add('window-sliding-down');
});
window.api.animation.onWindowHideAnimation(() => {
console.log('Starting window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('window-sliding-up');
});
window.api.animation.onSettingsWindowHideAnimation(() => {
console.log('Starting settings window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('settings-window-hide');
});
// --- UNCHANGED: Existing logic for listen window movement ---
window.api.animation.onListenWindowMoveToCenter(() => {
console.log('Moving listen window to center');
app.classList.add('listen-window-moving');
app.classList.remove('listen-window-left');
app.classList.add('listen-window-center');
setTimeout(() => {
app.classList.remove('listen-window-moving');
}, 350);
});
window.api.animation.onListenWindowMoveToLeft(() => {
console.log('Moving listen window to left');
app.classList.add('listen-window-moving');
app.classList.remove('listen-window-center');
app.classList.add('listen-window-left');
setTimeout(() => {
app.classList.remove('listen-window-moving');
}, 350);
});
}
});
</script>
<script>

View File

@ -1,7 +1,7 @@
<!DOCTYPE html>
<html>
<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>
<style>
html,

View File

@ -1,4 +1,5 @@
import { html, css, LitElement } from '../../ui/assets/lit-core-2.7.4.min.js';
import { parser, parser_write, parser_end, default_renderer } from '../../ui/assets/smd.js';
export class AskView extends LitElement {
static properties = {
@ -502,6 +503,7 @@ export class AskView extends LitElement {
padding: 0;
height: 0;
overflow: hidden;
border-top: none;
}
.text-input-container.no-response {
@ -719,15 +721,17 @@ export class AskView extends LitElement {
this.headerText = 'AI Response';
this.headerAnimating = false;
this.isStreaming = false;
this.accumulatedResponse = '';
this.marked = null;
this.hljs = null;
this.DOMPurify = null;
this.isLibrariesLoaded = false;
this.handleStreamChunk = this.handleStreamChunk.bind(this);
this.handleStreamEnd = this.handleStreamEnd.bind(this);
// SMD.js streaming markdown parser
this.smdParser = null;
this.smdContainer = null;
this.lastProcessedLength = 0;
this.handleSendText = this.handleSendText.bind(this);
this.handleTextKeydown = this.handleTextKeydown.bind(this);
this.handleCopy = this.handleCopy.bind(this);
@ -765,32 +769,41 @@ export class AskView extends LitElement {
if (container) this.resizeObserver.observe(container);
this.handleQuestionFromAssistant = (event, question) => {
console.log('📨 AskView: Received question from ListenView:', question);
console.log('AskView: Received question from ListenView:', question);
this.handleSendText(null, question);
};
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('ask:sendQuestionToRenderer', this.handleQuestionFromAssistant);
ipcRenderer.on('hide-text-input', () => {
console.log('📤 Hide text input signal received');
this.showTextInput = false;
this.requestUpdate();
});
ipcRenderer.on('ask:showTextInput', () => {
console.log('📤 Show text input signal received');
if (window.api) {
window.api.askView.onShowTextInput(() => {
console.log('Show text input signal received');
if (!this.showTextInput) {
this.showTextInput = true;
this.requestUpdate();
}
this.updateComplete.then(() => this.focusTextInput());
} else {
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 이벤트 리스너 등록 완료');
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;
const wasHidden = !this.showTextInput;
this.showTextInput = newState.showTextInput;
if (newState.showTextInput) {
if (wasHidden) {
this.updateComplete.then(() => this.focusTextInput());
} else {
this.focusTextInput();
}
}
});
console.log('AskView: IPC 이벤트 리스너 등록 완료');
}
}
@ -816,17 +829,12 @@ export class AskView extends LitElement {
Object.values(this.lineCopyTimeouts).forEach(timeout => clearTimeout(timeout));
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeListener('hide-text-input', () => { });
ipcRenderer.removeListener('ask:showTextInput', () => { });
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 이벤트 리스너 제거 완료');
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 이벤트 리스너 제거 필요');
}
}
@ -888,8 +896,8 @@ export class AskView extends LitElement {
}
handleCloseAskWindow() {
this.clearResponseContent();
ipcRenderer.invoke('ask:closeAskWindow');
// this.clearResponseContent();
window.api.askView.closeAskWindow();
}
handleCloseIfNoContent() {
@ -912,9 +920,9 @@ export class AskView extends LitElement {
this.isStreaming = false;
this.headerText = 'AI Response';
this.showTextInput = true;
this.accumulatedResponse = '';
this.requestUpdate();
this.renderContent();
this.lastProcessedLength = 0;
this.smdParser = null;
this.smdContainer = null;
}
handleInputFocus() {
@ -981,57 +989,94 @@ 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() {
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');
if (!responseContainer) return;
// Check loading state
if (this.isLoading) {
responseContainer.innerHTML = `
<div class="loading-dots">
<div class="loading-dot"></div><div class="loading-dot"></div><div class="loading-dot"></div>
</div>`;
<div class="loading-dots">
<div class="loading-dot"></div>
<div class="loading-dot"></div>
<div class="loading-dot"></div>
</div>`;
this.resetStreamingParser();
return;
}
// If there is no response, show empty state
if (!this.currentResponse) {
responseContainer.innerHTML = `<div class="empty-state">...</div>`;
this.resetStreamingParser();
return;
}
// Set streaming markdown parser
this.renderStreamingMarkdown(responseContainer);
let textToRender = this.isStreaming ? this.accumulatedResponse : this.currentResponse;
// After updating content, recalculate window height
this.adjustWindowHeightThrottled();
}
// 불완전한 마크다운 수정
textToRender = this.fixIncompleteMarkdown(textToRender);
textToRender = this.fixIncompleteCodeBlocks(textToRender);
resetStreamingParser() {
this.smdParser = null;
this.smdContainer = null;
this.lastProcessedLength = 0;
}
renderStreamingMarkdown(responseContainer) {
try {
// 파서가 없거나 컨테이너가 변경되었으면 새로 생성
if (!this.smdParser || this.smdContainer !== responseContainer) {
this.smdContainer = responseContainer;
this.smdContainer.innerHTML = '';
// smd.js의 default_renderer 사용
const renderer = default_renderer(this.smdContainer);
this.smdParser = parser(renderer);
this.lastProcessedLength = 0;
}
// 새로운 텍스트만 처리 (스트리밍 최적화)
const currentText = this.currentResponse;
const newText = currentText.slice(this.lastProcessedLength);
if (newText.length > 0) {
// 새로운 텍스트 청크를 파서에 전달
parser_write(this.smdParser, newText);
this.lastProcessedLength = currentText.length;
}
// 스트리밍이 완료되면 파서 종료
if (!this.isStreaming && !this.isLoading) {
parser_end(this.smdParser);
}
// 코드 하이라이팅 적용
if (this.hljs) {
responseContainer.querySelectorAll('pre code').forEach(block => {
if (!block.hasAttribute('data-highlighted')) {
this.hljs.highlightElement(block);
block.setAttribute('data-highlighted', 'true');
}
});
}
// 스크롤을 맨 아래로
responseContainer.scrollTop = responseContainer.scrollHeight;
} catch (error) {
console.error('Error rendering streaming markdown:', error);
// 에러 발생 시 기본 텍스트 렌더링으로 폴백
this.renderFallbackContent(responseContainer);
}
}
renderFallbackContent(responseContainer) {
const textToRender = this.currentResponse || '';
if (this.isLibrariesLoaded && this.marked && this.DOMPurify) {
try {
// 마크다운 파싱
@ -1040,42 +1085,13 @@ export class AskView extends LitElement {
// DOMPurify로 정제
const cleanHtml = this.DOMPurify.sanitize(parsedHtml, {
ALLOWED_TAGS: [
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'p',
'br',
'strong',
'b',
'em',
'i',
'ul',
'ol',
'li',
'blockquote',
'code',
'pre',
'a',
'img',
'table',
'thead',
'tbody',
'tr',
'th',
'td',
'hr',
'sup',
'sub',
'del',
'ins',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'b', 'em', 'i',
'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'img', 'table', 'thead',
'tbody', 'tr', 'th', 'td', 'hr', 'sup', 'sub', 'del', 'ins',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'id', 'target', 'rel'],
});
// HTML 적용
responseContainer.innerHTML = cleanHtml;
// 코드 하이라이팅 적용
@ -1084,12 +1100,8 @@ export class AskView extends LitElement {
this.hljs.highlightElement(block);
});
}
// 스크롤을 맨 아래로
responseContainer.scrollTop = responseContainer.scrollHeight;
} catch (error) {
console.error('Error rendering markdown:', error);
// 에러 발생 시 일반 텍스트로 표시
console.error('Error in fallback rendering:', error);
responseContainer.textContent = textToRender;
}
} else {
@ -1106,15 +1118,12 @@ export class AskView extends LitElement {
responseContainer.innerHTML = `<p>${basicHtml}</p>`;
}
// 🚀 After updating content, recalculate window height
this.adjustWindowHeightThrottled();
}
requestWindowResize(targetHeight) {
if (window.api && window.api.ask) {
window.api.ask.adjustWindowHeight(targetHeight);
if (window.api) {
window.api.askView.adjustWindowHeight(targetHeight);
}
}
@ -1263,28 +1272,13 @@ export class AskView extends LitElement {
async handleSendText(e, overridingText = '') {
const textInput = this.shadowRoot?.getElementById('textInput');
const text = (overridingText || textInput?.value || '').trim();
if (!text) return;
// if (!text) return;
textInput.value = '';
this.currentQuestion = text;
this.lineCopyState = {};
this.showTextInput = false;
this.isLoading = true;
this.isStreaming = false;
this.currentResponse = '';
this.accumulatedResponse = '';
this.startHeaderAnimation();
this.requestUpdate();
this.renderContent();
if (window.api && window.api.ask) {
window.api.ask.sendMessage(text).catch(error => {
if (window.api) {
window.api.askView.sendMessage(text).catch(error => {
console.error('Error sending text:', error);
this.isLoading = false;
this.isStreaming = false;
this.currentResponse = `Error: ${error.message}`;
this.renderContent();
});
}
}
@ -1306,14 +1300,16 @@ export class AskView extends LitElement {
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('isLoading')) {
// ✨ isLoading 또는 currentResponse가 변경될 때마다 뷰를 다시 그립니다.
if (changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
this.renderContent();
}
if (changedProperties.has('showTextInput') || changedProperties.has('isLoading')) {
if (changedProperties.has('showTextInput') || changedProperties.has('isLoading') || changedProperties.has('currentResponse')) {
this.adjustWindowHeightThrottled();
}
if (changedProperties.has('showTextInput') && this.showTextInput) {
this.focusTextInput();
}
@ -1334,6 +1330,7 @@ export class AskView extends LitElement {
render() {
const hasResponse = this.isLoading || this.currentResponse || this.isStreaming;
const headerText = this.isLoading ? 'Thinking...' : 'AI Response';
return html`
<div class="ask-container">
@ -1346,7 +1343,7 @@ export class AskView extends LitElement {
<path d="M8 12l2 2 4-4" />
</svg>
</div>
<span class="response-label ${this.headerAnimating ? 'animating' : ''}">${this.headerText}</span>
<span class="response-label">${headerText}</span>
</div>
<div class="header-right">
<span class="question-text">${this.getTruncatedQuestion(this.currentQuestion)}</span>
@ -1408,7 +1405,7 @@ export class AskView extends LitElement {
// Dynamically resize the BrowserWindow to fit current content
adjustWindowHeight() {
if (!window.api || !window.api.ask) return;
if (!window.api) return;
this.updateComplete.then(() => {
const headerEl = this.shadowRoot.querySelector('.response-header');
@ -1425,7 +1422,7 @@ export class AskView extends LitElement {
const targetHeight = Math.min(700, idealHeight);
window.api.ask.adjustWindowHeight(targetHeight);
window.api.askView.adjustWindowHeight("ask", targetHeight);
}).catch(err => console.error('AskView adjustWindowHeight error:', err));
}

1665
src/ui/assets/smd.js Normal file

File diff suppressed because it is too large Load Diff

View File

@ -453,8 +453,8 @@ export class ListenView extends LitElement {
if (this.isSessionActive) {
this.startTimer();
}
if (window.api && window.api.listen) {
window.api.listen.onSessionStateChanged((event, { isActive }) => {
if (window.api) {
window.api.listenView.onSessionStateChanged((event, { isActive }) => {
const wasActive = this.isSessionActive;
this.isSessionActive = isActive;
@ -513,7 +513,7 @@ export class ListenView extends LitElement {
}
adjustWindowHeight() {
if (!window.api || !window.api.listen) return;
if (!window.api) return;
this.updateComplete
.then(() => {
@ -536,7 +536,7 @@ export class ListenView extends LitElement {
`[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px`
);
window.api.listen.adjustWindowHeight(targetHeight);
window.api.listenView.adjustWindowHeight('listen', targetHeight);
})
.catch(error => {
console.error('Error in adjustWindowHeight:', error);

View File

@ -1,4 +1,3 @@
const { ipcRenderer } = require('electron');
const createAecModule = require('./aec.js');
let aecModPromise = null; // 한 번만 로드
@ -34,18 +33,15 @@ const SAMPLE_RATE = 24000;
const AUDIO_CHUNK_DURATION = 0.1;
const BUFFER_SIZE = 4096;
const isLinux = process.platform === 'linux';
const isMacOS = process.platform === 'darwin';
const isLinux = window.api.platform.isLinux;
const isMacOS = window.api.platform.isMacOS;
let mediaStream = null;
let micMediaStream = null;
let screenshotInterval = null;
let audioContext = null;
let audioProcessor = null;
let systemAudioContext = null;
let systemAudioProcessor = null;
let currentImageQuality = 'medium';
let lastScreenshotBase64 = null;
let systemAudioBuffer = [];
const MAX_SYSTEM_BUFFER_SIZE = 10;
@ -141,10 +137,6 @@ function runAecSync(micF32, sysF32) {
return micF32;
}
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
// 새로운 프레임 단위 처리 로직
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
const frameSize = 160; // AEC 모듈 초기화 시 설정한 프레임 크기
const numFrames = Math.floor(micF32.length / frameSize);
@ -198,7 +190,7 @@ function runAecSync(micF32, sysF32) {
// System audio data handler
window.api.audio.onSystemAudioData((event, { data }) => {
window.api.listenCapture.onSystemAudioData((event, { data }) => {
systemAudioBuffer.push({
data: data,
timestamp: Date.now(),
@ -336,7 +328,7 @@ async function setupMicProcessing(micStream) {
const pcm16 = convertFloat32ToInt16(processedChunk);
const b64 = arrayBufferToBase64(pcm16.buffer);
window.api.audio.sendAudioContent({
window.api.listenCapture.sendMicAudioContent({
data: b64,
mimeType: 'audio/pcm;rate=24000',
});
@ -369,7 +361,7 @@ function setupLinuxMicProcessing(micStream) {
const pcmData16 = convertFloat32ToInt16(chunk);
const base64Data = arrayBufferToBase64(pcmData16.buffer);
await window.api.audio.sendAudioContent({
await window.api.listenCapture.sendMicAudioContent({
data: base64Data,
mimeType: 'audio/pcm;rate=24000',
});
@ -403,7 +395,7 @@ function setupSystemAudioProcessing(systemStream) {
const base64Data = arrayBufferToBase64(pcmData16.buffer);
try {
await window.api.audio.sendSystemAudioContent({
await window.api.listenCapture.sendSystemAudioContent({
data: base64Data,
mimeType: 'audio/pcm;rate=24000',
});
@ -419,94 +411,10 @@ function setupSystemAudioProcessing(systemStream) {
return { context: systemAudioContext, processor: systemProcessor };
}
// ---------------------------
// Screenshot functions (exact from renderer.js)
// ---------------------------
async function captureScreenshot(imageQuality = 'medium', isManual = false) {
console.log(`Capturing ${isManual ? 'manual' : 'automated'} screenshot...`);
// Check rate limiting for automated screenshots only
if (!isManual && tokenTracker.shouldThrottle()) {
console.log('⚠️ Automated screenshot skipped due to rate limiting');
return;
}
try {
// Request screenshot from main process
const result = await window.api.audio.captureScreenshot({
quality: imageQuality,
});
if (result.success && result.base64) {
// Store the latest screenshot
lastScreenshotBase64 = result.base64;
// Note: sendResult is not defined in the original, this was likely an error
// Commenting out this section as it references undefined variable
/*
if (sendResult.success) {
// Track image tokens after successful send
const imageTokens = tokenTracker.calculateImageTokens(result.width || 1920, result.height || 1080);
tokenTracker.addTokens(imageTokens, 'image');
console.log(`📊 Image sent successfully - ${imageTokens} tokens used (${result.width}x${result.height})`);
} else {
console.error('Failed to send image:', sendResult.error);
}
*/
} else {
console.error('Failed to capture screenshot:', result.error);
}
} catch (error) {
console.error('Error capturing screenshot:', error);
}
}
async function captureManualScreenshot(imageQuality = null) {
console.log('Manual screenshot triggered');
const quality = imageQuality || currentImageQuality;
await captureScreenshot(quality, true);
}
async function getCurrentScreenshot() {
try {
// First try to get a fresh screenshot from main process
const result = await window.api.audio.getCurrentScreenshot();
if (result.success && result.base64) {
console.log('📸 Got fresh screenshot from main process');
return result.base64;
}
// If no screenshot available, capture one now
console.log('📸 No screenshot available, capturing new one');
const captureResult = await window.api.audio.captureScreenshot({
quality: currentImageQuality,
});
if (captureResult.success && captureResult.base64) {
lastScreenshotBase64 = captureResult.base64;
return captureResult.base64;
}
// Fallback to last stored screenshot
if (lastScreenshotBase64) {
console.log('📸 Using cached screenshot');
return lastScreenshotBase64;
}
throw new Error('Failed to get screenshot');
} catch (error) {
console.error('Error getting current screenshot:', error);
return null;
}
}
// ---------------------------
// Main capture functions (exact from renderer.js)
// ---------------------------
async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'medium') {
// Store the image quality for manual screenshots
currentImageQuality = imageQuality;
// Reset token tracker when starting new capture session
tokenTracker.reset();
@ -514,19 +422,25 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
try {
if (isMacOS) {
const sessionActive = await window.api.listenCapture.isSessionActive();
if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete');
}
// On macOS, use SystemAudioDump for audio and getDisplayMedia for screen
console.log('Starting macOS capture with SystemAudioDump...');
// Start macOS audio capture
const audioResult = await window.api.audio.startMacosAudio();
const audioResult = await window.api.listenCapture.startMacosSystemAudio();
if (!audioResult.success) {
console.warn('[listenCapture] macOS audio start failed:', audioResult.error);
// 이미 실행 중 → stop 후 재시도
if (audioResult.error === 'already_running') {
await window.api.audio.stopMacosAudio();
await window.api.listenCapture.stopMacosSystemAudio();
await new Promise(r => setTimeout(r, 500));
const retry = await window.api.audio.startMacosAudio();
const retry = await window.api.listenCapture.startMacosSystemAudio();
if (!retry.success) {
throw new Error('Retry failed: ' + retry.error);
}
@ -535,13 +449,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
}
}
// Initialize screen capture in main process
const screenResult = await window.api.audio.startScreenCapture();
if (!screenResult.success) {
throw new Error('Failed to start screen capture: ' + screenResult.error);
}
try {
micMediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
@ -565,6 +472,12 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
console.log('macOS screen capture started - audio handled by SystemAudioDump');
} else if (isLinux) {
const sessionActive = await window.api.listenCapture.isSessionActive();
if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete');
}
// Linux - use display media for screen capture and getUserMedia for microphone
mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: {
@ -603,14 +516,8 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
// Windows - capture mic and system audio separately using native loopback
console.log('Starting Windows capture with native loopback audio...');
// Start screen capture in main process for screenshots
const screenResult = await window.api.audio.startScreenCapture();
if (!screenResult.success) {
throw new Error('Failed to start screen capture: ' + screenResult.error);
}
// Ensure STT sessions are initialized before starting audio capture
const sessionActive = await window.api.audio.isSessionActive();
const sessionActive = await window.api.listenCapture.isSessionActive();
if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete');
}
@ -657,20 +564,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
// Continue without system audio
}
}
// Start capturing screenshots - check if manual mode
if (screenshotIntervalSeconds === 'manual' || screenshotIntervalSeconds === 'Manual') {
console.log('Manual mode enabled - screenshots will be captured on demand only');
// Don't start automatic capture in manual mode
} else {
// 스크린샷 기능 활성화 (chatModel에서 사용)
const intervalMilliseconds = parseInt(screenshotIntervalSeconds) * 1000;
screenshotInterval = setInterval(() => captureScreenshot(imageQuality), intervalMilliseconds);
// Capture first screenshot immediately
setTimeout(() => captureScreenshot(imageQuality), 100);
console.log(`📸 Screenshot capture enabled with ${screenshotIntervalSeconds}s interval`);
}
} catch (err) {
console.error('Error starting capture:', err);
// Note: pickleGlass.e() is not available in this context, commenting out
@ -679,11 +572,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
}
function stopCapture() {
if (screenshotInterval) {
clearInterval(screenshotInterval);
screenshotInterval = null;
}
// Clean up microphone resources
if (audioProcessor) {
audioProcessor.disconnect();
@ -714,14 +602,9 @@ function stopCapture() {
micMediaStream = null;
}
// Stop screen capture in main process
window.api.audio.stopScreenCapture().catch(err => {
console.error('Error stopping screen capture:', err);
});
// Stop macOS audio capture if running
if (isMacOS) {
window.api.audio.stopMacosAudio().catch(err => {
window.api.listenCapture.stopMacosSystemAudio().catch(err => {
console.error('Error stopping macOS audio:', err);
});
}
@ -736,19 +619,14 @@ module.exports = {
disposeAec, // 필요시 Rust 객체 파괴
startCapture,
stopCapture,
captureManualScreenshot,
getCurrentScreenshot,
isLinux,
isMacOS,
};
// Expose functions to global scope for external access (exact from renderer.js)
if (typeof window !== 'undefined') {
window.captureManualScreenshot = captureManualScreenshot;
window.listenCapture = module.exports;
window.pickleGlass = window.pickleGlass || {};
window.pickleGlass.startCapture = startCapture;
window.pickleGlass.stopCapture = stopCapture;
window.pickleGlass.captureManualScreenshot = captureManualScreenshot;
window.pickleGlass.getCurrentScreenshot = getCurrentScreenshot;
}

View File

@ -1,5 +1,4 @@
// renderer.js
const { ipcRenderer } = require('electron');
const listenCapture = require('./listenCapture.js');
const params = new URLSearchParams(window.location.search);
const isListenView = params.get('view') === 'listen';
@ -15,7 +14,7 @@ window.pickleGlass = {
};
window.api.audio.onChangeListenCaptureState((_event, { status }) => {
window.api.renderer.onChangeListenCaptureState((_event, { status }) => {
if (!isListenView) {
console.log('[Renderer] Non-listen view: ignoring capture-state change');
return;

View File

@ -95,15 +95,15 @@ export class SttView extends LitElement {
connectedCallback() {
super.connectedCallback();
if (window.api && window.api.listen) {
window.api.listen.onSttUpdate(this.handleSttUpdate);
if (window.api) {
window.api.sttView.onSttUpdate(this.handleSttUpdate);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (window.api && window.api.listen) {
window.api.listen.removeOnSttUpdate(this.handleSttUpdate);
if (window.api) {
window.api.sttView.removeOnSttUpdate(this.handleSttUpdate);
}
}

View File

@ -262,8 +262,8 @@ export class SummaryView extends LitElement {
connectedCallback() {
super.connectedCallback();
if (window.api && window.api.listen) {
window.api.listen.onSummaryUpdate((event, data) => {
if (window.api) {
window.api.summaryView.onSummaryUpdate((event, data) => {
this.structuredData = data;
this.requestUpdate();
});
@ -272,8 +272,8 @@ export class SummaryView extends LitElement {
disconnectedCallback() {
super.disconnectedCallback();
if (window.api && window.api.listen) {
window.api.listen.removeOnSummaryUpdate(() => {});
if (window.api) {
window.api.summaryView.removeAllSummaryUpdateListeners();
}
}
@ -406,9 +406,9 @@ export class SummaryView extends LitElement {
async handleRequestClick(requestText) {
console.log('🔥 Analysis request clicked:', requestText);
if (window.api && window.api.listen) {
if (window.api) {
try {
const result = await ipcRenderer.invoke('ask:sendQuestionToMain', requestText);
const result = await window.api.summaryView.sendQuestionFromSummary(requestText);
if (result.success) {
console.log('✅ Question sent to AskView successfully');

View File

@ -1,5 +1,5 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js';
// import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; // 제거됨
export class SettingsView extends LitElement {
static styles = css`
@ -531,7 +531,6 @@ export class SettingsView extends LitElement {
this.ollamaStatus = { installed: false, running: false };
this.ollamaModels = [];
this.installingModels = {}; // { modelName: progress }
this.progressTracker = getOllamaProgressTracker();
// Whisper related
this.whisperModels = [];
this.whisperProgressTracker = null; // Will be initialized when needed
@ -543,9 +542,10 @@ export class SettingsView extends LitElement {
}
async loadAutoUpdateSetting() {
if (!window.api) return;
this.autoUpdateLoading = true;
try {
const enabled = await window.api.feature.settings.getAutoUpdate();
const enabled = await window.api.settingsView.getAutoUpdate();
this.autoUpdateEnabled = enabled;
console.log('Auto-update setting loaded:', enabled);
} catch (e) {
@ -557,12 +557,12 @@ export class SettingsView extends LitElement {
}
async handleToggleAutoUpdate() {
if (this.autoUpdateLoading) return;
if (!window.api || this.autoUpdateLoading) return;
this.autoUpdateLoading = true;
this.requestUpdate();
try {
const newValue = !this.autoUpdateEnabled;
const result = await window.api.feature.settings.setAutoUpdate(newValue);
const result = await window.api.settingsView.setAutoUpdate(newValue);
if (result && result.success) {
this.autoUpdateEnabled = newValue;
} else {
@ -575,31 +575,64 @@ export class SettingsView extends LitElement {
this.requestUpdate();
}
async loadLocalAIStatus() {
try {
// Load Ollama status
const ollamaStatus = await window.api.settingsView.getOllamaStatus();
if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || [];
}
// Load Whisper models status only if Whisper is enabled
if (this.apiKeys?.whisper === 'local') {
const whisperModelsResult = await window.api.settingsView.getWhisperInstalledModels();
if (whisperModelsResult?.success) {
const installedWhisperModels = whisperModelsResult.models;
if (this.providerConfig?.whisper) {
this.providerConfig.whisper.sttModels.forEach(m => {
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
if (installedInfo) {
m.installed = installedInfo.installed;
}
});
}
}
}
// Trigger UI update
this.requestUpdate();
} catch (error) {
console.error('Error loading LocalAI status:', error);
}
}
//////// after_modelStateService ////////
async loadInitialData() {
if (!window.api) return;
this.isLoading = true;
try {
const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = await Promise.all([
window.api.feature.settings.getCurrentUser(),
window.api.feature.settings.getProviderConfig(), // Provider 설정 로드
window.api.feature.settings.getAllKeys(),
window.api.feature.settings.getAvailableModels({ type: 'llm' }),
window.api.feature.settings.getAvailableModels({ type: 'stt' }),
window.api.feature.settings.getSelectedModels(),
window.api.feature.settings.getPresets(),
window.api.feature.settings.getContentProtectionStatus(),
window.api.feature.settings.getCurrentShortcuts(),
window.api.feature.settings.getOllamaStatus(),
window.api.feature.settings.getWhisperInstalledModels()
// Load essential data first
const [userState, modelSettings, presets, contentProtection, shortcuts] = await Promise.all([
window.api.settingsView.getCurrentUser(),
window.api.settingsView.getModelSettings(), // Facade call
window.api.settingsView.getPresets(),
window.api.settingsView.getContentProtectionStatus(),
window.api.settingsView.getCurrentShortcuts()
]);
if (userState && userState.isLoggedIn) this.firebaseUser = userState;
this.providerConfig = config;
this.apiKeys = storedKeys;
this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt;
this.selectedLlm = selectedModels.llm;
this.selectedStt = selectedModels.stt;
if (modelSettings.success) {
const { config, storedKeys, availableLlm, availableStt, selectedModels } = modelSettings.data;
this.providerConfig = config;
this.apiKeys = storedKeys;
this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt;
this.selectedLlm = selectedModels.llm;
this.selectedStt = selectedModels.stt;
}
this.presets = presets || [];
this.isContentProtectionOn = contentProtection;
this.shortcuts = shortcuts || {};
@ -607,23 +640,9 @@ export class SettingsView extends LitElement {
const firstUserPreset = this.presets.find(p => p.is_default === 0);
if (firstUserPreset) this.selectedPreset = firstUserPreset;
}
// Ollama status
if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || [];
}
// Whisper status
if (whisperModelsResult?.success) {
const installedWhisperModels = whisperModelsResult.models;
if (this.providerConfig.whisper) {
this.providerConfig.whisper.sttModels.forEach(m => {
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
if (installedInfo) {
m.installed = installedInfo.installed;
}
});
}
}
// Load LocalAI status asynchronously to improve initial load time
this.loadLocalAIStatus();
} catch (error) {
console.error('Error loading initial settings data:', error);
} finally {
@ -631,6 +650,7 @@ export class SettingsView extends LitElement {
}
}
async handleSaveKey(provider) {
const input = this.shadowRoot.querySelector(`#key-input-${provider}`);
if (!input) return;
@ -641,7 +661,7 @@ export class SettingsView extends LitElement {
this.saving = true;
// First ensure Ollama is installed and running
const ensureResult = await window.api.feature.settings.ensureOllamaReady();
const ensureResult = await window.api.settingsView.ensureOllamaReady();
if (!ensureResult.success) {
alert(`Failed to setup Ollama: ${ensureResult.error}`);
this.saving = false;
@ -649,10 +669,9 @@ export class SettingsView extends LitElement {
}
// Now validate (which will check if service is running)
const result = await window.api.feature.settings.validateKey({ provider, key: 'local' });
const result = await window.api.settingsView.validateKey({ provider, key: 'local' });
if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: 'local' };
await this.refreshModelData();
await this.refreshOllamaStatus();
} else {
@ -665,10 +684,9 @@ export class SettingsView extends LitElement {
// For Whisper, just enable it
if (provider === 'whisper') {
this.saving = true;
const result = await window.api.feature.settings.validateKey({ provider, key: 'local' });
const result = await window.api.settingsView.validateKey({ provider, key: 'local' });
if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: 'local' };
await this.refreshModelData();
} else {
alert(`Failed to enable Whisper: ${result.error}`);
@ -679,10 +697,9 @@ export class SettingsView extends LitElement {
// For other providers, use the normal flow
this.saving = true;
const result = await window.api.feature.settings.validateKey({ provider, key });
const result = await window.api.settingsView.validateKey({ provider, key });
if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: key };
await this.refreshModelData();
} else {
alert(`Failed to save ${provider} key: ${result.error}`);
@ -692,8 +709,9 @@ export class SettingsView extends LitElement {
}
async handleClearKey(provider) {
console.log(`[SettingsView] handleClearKey: ${provider}`);
this.saving = true;
await window.api.feature.settings.removeApiKey({ provider });
await window.api.settingsView.removeApiKey(provider);
this.apiKeys = { ...this.apiKeys, [provider]: '' };
await this.refreshModelData();
this.saving = false;
@ -701,10 +719,10 @@ export class SettingsView extends LitElement {
async refreshModelData() {
const [availableLlm, availableStt, selected, storedKeys] = await Promise.all([
window.api.feature.settings.getAvailableModels({ type: 'llm' }),
window.api.feature.settings.getAvailableModels({ type: 'stt' }),
window.api.feature.settings.getSelectedModels(),
window.api.feature.settings.getAllKeys()
window.api.settingsView.getAvailableModels({ type: 'llm' }),
window.api.settingsView.getAvailableModels({ type: 'stt' }),
window.api.settingsView.getSelectedModels(),
window.api.settingsView.getAllKeys()
]);
this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt;
@ -755,7 +773,7 @@ export class SettingsView extends LitElement {
}
this.saving = true;
await window.api.feature.settings.setSelectedModel({ type, modelId });
await window.api.settingsView.setSelectedModel({ type, modelId });
if (type === 'llm') this.selectedLlm = modelId;
if (type === 'stt') this.selectedStt = modelId;
this.isLlmListVisible = false;
@ -765,7 +783,7 @@ export class SettingsView extends LitElement {
}
async refreshOllamaStatus() {
const ollamaStatus = await window.api.feature.settings.getOllamaStatus();
const ollamaStatus = await window.api.settingsView.getOllamaStatus();
if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || [];
@ -773,31 +791,42 @@ export class SettingsView extends LitElement {
}
async installOllamaModel(modelName) {
// Mark as installing
this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.requestUpdate();
try {
// Use the clean progress tracker - no manual event management needed
const success = await this.progressTracker.installModel(modelName, (progress) => {
this.installingModels = { ...this.installingModels, [modelName]: progress };
this.requestUpdate();
});
if (success) {
// Refresh status after installation
await this.refreshOllamaStatus();
await this.refreshModelData();
// Auto-select the model after installation
await this.selectModel('llm', modelName);
} else {
alert(`Installation of ${modelName} was cancelled`);
// Ollama 모델 다운로드 시작
this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.requestUpdate();
// 진행률 이벤트 리스너 설정 - 통합 LocalAI 이벤트 사용
const progressHandler = (event, data) => {
if (data.service === 'ollama' && data.model === modelName) {
this.installingModels = { ...this.installingModels, [modelName]: data.progress || 0 };
this.requestUpdate();
}
};
// 통합 LocalAI 이벤트 리스너 등록
window.api.settingsView.onLocalAIInstallProgress(progressHandler);
try {
const result = await window.api.settingsView.pullOllamaModel(modelName);
if (result.success) {
console.log(`[SettingsView] Model ${modelName} installed successfully`);
delete this.installingModels[modelName];
this.requestUpdate();
// 상태 새로고침
await this.refreshOllamaStatus();
await this.refreshModelData();
} else {
throw new Error(result.error || 'Installation failed');
}
} finally {
// 통합 LocalAI 이벤트 리스너 제거
window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
}
} catch (error) {
console.error(`[SettingsView] Error installing model ${modelName}:`, error);
alert(`Error installing ${modelName}: ${error.message}`);
} finally {
// Automatic cleanup - no manual event listener management
delete this.installingModels[modelName];
this.requestUpdate();
}
@ -809,34 +838,52 @@ export class SettingsView extends LitElement {
this.requestUpdate();
try {
// Set up progress listener
const progressHandler = (event, { modelId: id, progress }) => {
if (id === modelId) {
this.installingModels = { ...this.installingModels, [modelId]: progress };
// Set up progress listener - 통합 LocalAI 이벤트 사용
const progressHandler = (event, data) => {
if (data.service === 'whisper' && data.model === modelId) {
this.installingModels = { ...this.installingModels, [modelId]: data.progress || 0 };
this.requestUpdate();
}
};
window.api.feature.settings.onWhisperDownloadProgress(progressHandler);
window.api.settingsView.onLocalAIInstallProgress(progressHandler);
// Start download
const result = await window.api.feature.settings.downloadWhisperModel(modelId);
const result = await window.api.settingsView.downloadWhisperModel(modelId);
if (result.success) {
// Update the model's installed status
if (this.providerConfig?.whisper?.sttModels) {
const modelInfo = this.providerConfig.whisper.sttModels.find(m => m.id === modelId);
if (modelInfo) {
modelInfo.installed = true;
}
}
// Remove from installing models
delete this.installingModels[modelId];
this.requestUpdate();
// Reload LocalAI status to get fresh data
await this.loadLocalAIStatus();
// Auto-select the model after download
await this.selectModel('stt', modelId);
} else {
// Remove from installing models on failure too
delete this.installingModels[modelId];
this.requestUpdate();
alert(`Failed to download Whisper model: ${result.error}`);
}
// Cleanup
window.api.feature.settings.removeOnWhisperDownloadProgress(progressHandler);
window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
} catch (error) {
console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error);
alert(`Error downloading ${modelId}: ${error.message}`);
} finally {
// Remove from installing models on error
delete this.installingModels[modelId];
this.requestUpdate();
alert(`Error downloading ${modelId}: ${error.message}`);
}
}
@ -850,24 +897,18 @@ export class SettingsView extends LitElement {
return null;
}
async handleWhisperModelSelect(modelId) {
if (!modelId) return;
// Select the model (will trigger download if needed)
await this.selectModel('stt', modelId);
}
handleUsePicklesKey(e) {
e.preventDefault()
if (this.wasJustDragged) return
console.log("Requesting Firebase authentication from main process...")
window.api.feature.settings.startFirebaseAuth();
window.api.settingsView.startFirebaseAuth();
}
//////// after_modelStateService ////////
openShortcutEditor() {
window.api.feature.settings.openShortcutEditor();
window.api.settingsView.openShortcutSettingsWindow();
}
connectedCallback() {
@ -877,6 +918,8 @@ export class SettingsView extends LitElement {
this.setupIpcListeners();
this.setupWindowResize();
this.loadAutoUpdateSetting();
// Force one height calculation immediately (innerHeight may be 0 at first)
setTimeout(() => this.updateScrollHeight(), 0);
}
disconnectedCallback() {
@ -889,7 +932,7 @@ export class SettingsView extends LitElement {
const installingModels = Object.keys(this.installingModels);
if (installingModels.length > 0) {
installingModels.forEach(modelName => {
this.progressTracker.cancelInstallation(modelName);
window.api.settingsView.cancelOllamaInstallation(modelName);
});
}
}
@ -905,6 +948,8 @@ export class SettingsView extends LitElement {
}
setupIpcListeners() {
if (!window.api) return;
this._userStateListener = (event, userState) => {
console.log('[SettingsView] Received user-state-changed:', userState);
if (userState && userState.isLoggedIn) {
@ -913,7 +958,8 @@ export class SettingsView extends LitElement {
this.firebaseUser = null;
}
this.loadAutoUpdateSetting();
this.requestUpdate();
// Reload model settings when user state changes (Firebase login/logout)
this.loadInitialData();
};
this._settingsUpdatedListener = (event, settings) => {
@ -926,7 +972,7 @@ export class SettingsView extends LitElement {
this._presetsUpdatedListener = async (event) => {
console.log('[SettingsView] Received presets-updated, refreshing presets');
try {
const presets = await window.api.feature.settings.getPresets();
const presets = await window.api.settingsView.getPresets();
this.presets = presets || [];
// 현재 선택된 프리셋이 삭제되었는지 확인 (사용자 프리셋만 고려)
@ -945,24 +991,26 @@ export class SettingsView extends LitElement {
this.shortcuts = keybinds;
};
window.api.feature.settings.onUserStateChanged(this._userStateListener);
window.api.feature.settings.onSettingsUpdated(this._settingsUpdatedListener);
window.api.feature.settings.onPresetsUpdated(this._presetsUpdatedListener);
window.api.feature.settings.onShortcutsUpdated(this._shortcutListener);
window.api.settingsView.onUserStateChanged(this._userStateListener);
window.api.settingsView.onSettingsUpdated(this._settingsUpdatedListener);
window.api.settingsView.onPresetsUpdated(this._presetsUpdatedListener);
window.api.settingsView.onShortcutsUpdated(this._shortcutListener);
}
cleanupIpcListeners() {
if (!window.api) return;
if (this._userStateListener) {
window.api.feature.settings.removeOnUserStateChanged(this._userStateListener);
window.api.settingsView.removeOnUserStateChanged(this._userStateListener);
}
if (this._settingsUpdatedListener) {
window.api.feature.settings.removeOnSettingsUpdated(this._settingsUpdatedListener);
window.api.settingsView.removeOnSettingsUpdated(this._settingsUpdatedListener);
}
if (this._presetsUpdatedListener) {
window.api.feature.settings.removeOnPresetsUpdated(this._presetsUpdatedListener);
window.api.settingsView.removeOnPresetsUpdated(this._presetsUpdatedListener);
}
if (this._shortcutListener) {
window.api.feature.settings.removeOnShortcutsUpdated(this._shortcutListener);
window.api.settingsView.removeOnShortcutsUpdated(this._shortcutListener);
}
}
@ -984,11 +1032,13 @@ export class SettingsView extends LitElement {
}
updateScrollHeight() {
const windowHeight = window.innerHeight;
const maxHeight = windowHeight;
// Electron 일부 시점에서 window.innerHeight 가 0 으로 보고되는 버그 보호
const rawHeight = window.innerHeight || (window.screen ? window.screen.height : 0);
const MIN_HEIGHT = 300; // 최소 보장 높이
const maxHeight = Math.max(MIN_HEIGHT, rawHeight);
this.style.maxHeight = `${maxHeight}px`;
const container = this.shadowRoot?.querySelector('.settings-container');
if (container) {
container.style.maxHeight = `${maxHeight}px`;
@ -996,20 +1046,16 @@ export class SettingsView extends LitElement {
}
handleMouseEnter = () => {
window.api.window.cancelHideSettingsWindow();
window.api.settingsView.cancelHideSettingsWindow();
// Recalculate height in case it was set to 0 before
this.updateScrollHeight();
}
handleMouseLeave = () => {
window.api.window.hideSettingsWindow();
window.api.settingsView.hideSettingsWindow();
}
// getMainShortcuts() {
// return [
// { name: 'Show / Hide', key: '\\' },
// { name: 'Ask Anything', key: '↵' },
// { name: 'Scroll AI Response', key: '↕' }
// ];
// }
getMainShortcuts() {
return [
{ name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility },
@ -1050,18 +1096,18 @@ export class SettingsView extends LitElement {
handleMoveLeft() {
console.log('Move Left clicked');
window.api.feature.settings.moveWindowStep('left');
window.api.settingsView.moveWindowStep('left');
}
handleMoveRight() {
console.log('Move Right clicked');
window.api.feature.settings.moveWindowStep('right');
window.api.settingsView.moveWindowStep('right');
}
async handlePersonalize() {
console.log('Personalize clicked');
try {
await window.api.window.openLoginPage();
await window.api.settingsView.openPersonalizePage();
} catch (error) {
console.error('Failed to open personalize page:', error);
}
@ -1069,7 +1115,7 @@ export class SettingsView extends LitElement {
async handleToggleInvisibility() {
console.log('Toggle Invisibility clicked');
this.isContentProtectionOn = await window.api.window.toggleContentProtection();
this.isContentProtectionOn = await window.api.settingsView.toggleContentProtection();
this.requestUpdate();
}
@ -1079,7 +1125,7 @@ export class SettingsView extends LitElement {
const newApiKey = input.value;
try {
const result = await window.api.feature.settings.saveApiKey(newApiKey);
const result = await window.api.settingsView.saveApiKey(newApiKey);
if (result.success) {
console.log('API Key saved successfully via IPC.');
this.apiKey = newApiKey;
@ -1092,32 +1138,27 @@ export class SettingsView extends LitElement {
}
}
async handleClearApiKey() {
console.log('Clear API Key clicked');
await window.api.feature.settings.removeApiKey();
this.apiKey = null;
this.requestUpdate();
}
handleQuit() {
console.log('Quit clicked');
window.api.window.quitApplication();
window.api.settingsView.quitApplication();
}
handleFirebaseLogout() {
console.log('Firebase Logout clicked');
window.api.window.firebaseLogout();
window.api.settingsView.firebaseLogout();
}
async handleOllamaShutdown() {
console.log('[SettingsView] Shutting down Ollama service...');
if (!window.api) return;
try {
// Show loading state
this.ollamaStatus = { ...this.ollamaStatus, running: false };
this.requestUpdate();
const result = await window.api.feature.settings.shutdownOllama(false); // Graceful shutdown
const result = await window.api.settingsView.shutdownOllama(false); // Graceful shutdown
if (result.success) {
console.log('[SettingsView] Ollama shut down successfully');
@ -1135,139 +1176,288 @@ export class SettingsView extends LitElement {
}
}
//////// after_modelStateService ////////
render() {
if (this.isLoading) {
return html`
<div class="settings-container">
<div class="loading-state">
<div class="loading-spinner"></div>
<span>Loading...</span>
</div>
</div>
`;
}
//////// before_modelStateService ////////
// render() {
// if (this.isLoading) {
// return html`
// <div class="settings-container">
// <div class="loading-state">
// <div class="loading-spinner"></div>
// <span>Loading...</span>
// </div>
// </div>
// `;
// }
const loggedIn = !!this.firebaseUser;
// const loggedIn = !!this.firebaseUser;
const apiKeyManagementHTML = html`
<div class="api-key-section">
${Object.entries(this.providerConfig)
.filter(([id, config]) => !id.includes('-glass'))
.map(([id, config]) => {
if (id === 'ollama') {
// Special UI for Ollama
return html`
<div class="provider-key-group">
<label>${config.name} (Local)</label>
${this.ollamaStatus.installed && this.ollamaStatus.running ? html`
<div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8);">
Ollama is running
</div>
<button class="settings-button full-width danger" @click=${this.handleOllamaShutdown}>
Stop Ollama Service
</button>
` : this.ollamaStatus.installed ? html`
<div style="padding: 8px; background: rgba(255,200,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(255,200,0,0.8);">
Ollama installed but not running
</div>
<button class="settings-button full-width" @click=${() => this.handleSaveKey(id)}>
Start Ollama
</button>
` : html`
<div style="padding: 8px; background: rgba(255,100,100,0.1); border-radius: 4px; font-size: 11px; color: rgba(255,100,100,0.8);">
Ollama not installed
</div>
<button class="settings-button full-width" @click=${() => this.handleSaveKey(id)}>
Install & Setup Ollama
</button>
`}
</div>
`;
}
if (id === 'whisper') {
// Simplified UI for Whisper without model selection
return html`
<div class="provider-key-group">
<label>${config.name} (Local STT)</label>
${this.apiKeys[id] === 'local' ? html`
<div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8); margin-bottom: 8px;">
Whisper is enabled
</div>
<button class="settings-button full-width danger" @click=${() => this.handleClearKey(id)}>
Disable Whisper
</button>
` : html`
<button class="settings-button full-width" @click=${() => this.handleSaveKey(id)}>
Enable Whisper STT
</button>
`}
</div>
`;
}
// Regular providers
return html`
<div class="provider-key-group">
<label for="key-input-${id}">${config.name} API Key</label>
<input type="password" id="key-input-${id}"
placeholder=${loggedIn ? "Using Pickle's Key" : `Enter ${config.name} API Key`}
.value=${this.apiKeys[id] || ''}
>
<div class="key-buttons">
<button class="settings-button" @click=${() => this.handleSaveKey(id)} >Save</button>
<button class="settings-button danger" @click=${() => this.handleClearKey(id)} }>Clear</button>
</div>
</div>
`;
})}
</div>
`;
const getModelName = (type, id) => {
const models = type === 'llm' ? this.availableLlmModels : this.availableSttModels;
const model = models.find(m => m.id === id);
return model ? model.name : id;
}
// return html`
// <div class="settings-container">
// <div class="header-section">
// <div>
// <h1 class="app-title">Pickle Glass</h1>
// <div class="account-info">
// ${this.firebaseUser
// ? html`Account: ${this.firebaseUser.email || 'Logged In'}`
// : this.apiKey && this.apiKey.length > 10
// ? html`API Key: ${this.apiKey.substring(0, 6)}...${this.apiKey.substring(this.apiKey.length - 6)}`
// : `Account: Not Logged In`
// }
// </div>
// </div>
// <div class="invisibility-icon ${this.isContentProtectionOn ? 'visible' : ''}" title="Invisibility is On">
// <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path d="M9.785 7.41787C8.7 7.41787 7.79 8.19371 7.55667 9.22621C7.0025 8.98704 6.495 9.05121 6.11 9.22037C5.87083 8.18204 4.96083 7.41787 3.88167 7.41787C2.61583 7.41787 1.58333 8.46204 1.58333 9.75121C1.58333 11.0404 2.61583 12.0845 3.88167 12.0845C5.08333 12.0845 6.06333 11.1395 6.15667 9.93787C6.355 9.79787 6.87417 9.53537 7.51 9.94954C7.615 11.1454 8.58333 12.0845 9.785 12.0845C11.0508 12.0845 12.0833 11.0404 12.0833 9.75121C12.0833 8.46204 11.0508 7.41787 9.785 7.41787ZM3.88167 11.4195C2.97167 11.4195 2.2425 10.6729 2.2425 9.75121C2.2425 8.82954 2.9775 8.08287 3.88167 8.08287C4.79167 8.08287 5.52083 8.82954 5.52083 9.75121C5.52083 10.6729 4.79167 11.4195 3.88167 11.4195ZM9.785 11.4195C8.875 11.4195 8.14583 10.6729 8.14583 9.75121C8.14583 8.82954 8.875 8.08287 9.785 8.08287C10.695 8.08287 11.43 8.82954 11.43 9.75121C11.43 10.6729 10.6892 11.4195 9.785 11.4195ZM12.6667 5.95954H1V6.83454H12.6667V5.95954ZM8.8925 1.36871C8.76417 1.08287 8.4375 0.931207 8.12833 1.03037L6.83333 1.46204L5.5325 1.03037L5.50333 1.02454C5.19417 0.93704 4.8675 1.10037 4.75083 1.39787L3.33333 5.08454H10.3333L8.91 1.39787L8.8925 1.36871Z" fill="white"/>
// </svg>
// </div>
// </div>
const modelSelectionHTML = html`
<div class="model-selection-section">
<div class="model-select-group">
<label>LLM Model: <strong>${getModelName('llm', this.selectedLlm) || 'Not Set'}</strong></label>
<button class="settings-button full-width" @click=${() => this.toggleModelList('llm')} ?disabled=${this.saving || this.availableLlmModels.length === 0}>
Change LLM Model
</button>
${this.isLlmListVisible ? html`
<div class="model-list">
${this.availableLlmModels.map(model => {
const isOllama = this.getProviderForModel('llm', model.id) === 'ollama';
const ollamaModel = isOllama ? this.ollamaModels.find(m => m.name === model.id) : null;
const isInstalling = this.installingModels[model.id] !== undefined;
const installProgress = this.installingModels[model.id] || 0;
return html`
<div class="model-item ${this.selectedLlm === model.id ? 'selected' : ''}"
@click=${() => this.selectModel('llm', model.id)}>
<span>${model.name}</span>
${isOllama ? html`
${isInstalling ? html`
<div class="install-progress">
<div class="install-progress-bar" style="width: ${installProgress}%"></div>
</div>
` : ollamaModel?.installed ? html`
<span class="model-status installed"> Installed</span>
` : html`
<span class="model-status not-installed">Click to install</span>
`}
` : ''}
</div>
`;
})}
</div>
` : ''}
</div>
<div class="model-select-group">
<label>STT Model: <strong>${getModelName('stt', this.selectedStt) || 'Not Set'}</strong></label>
<button class="settings-button full-width" @click=${() => this.toggleModelList('stt')} ?disabled=${this.saving || this.availableSttModels.length === 0}>
Change STT Model
</button>
${this.isSttListVisible ? html`
<div class="model-list">
${this.availableSttModels.map(model => {
const isWhisper = this.getProviderForModel('stt', model.id) === 'whisper';
const whisperModel = isWhisper && this.providerConfig?.whisper?.sttModels
? this.providerConfig.whisper.sttModels.find(m => m.id === model.id)
: null;
const isInstalling = this.installingModels[model.id] !== undefined;
const installProgress = this.installingModels[model.id] || 0;
return html`
<div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}"
@click=${() => this.selectModel('stt', model.id)}>
<span>${model.name}</span>
${isWhisper ? html`
${isInstalling ? html`
<div class="install-progress">
<div class="install-progress-bar" style="width: ${installProgress}%"></div>
</div>
` : whisperModel?.installed ? html`
<span class="model-status installed"> Installed</span>
` : html`
<span class="model-status not-installed">Not Installed</span>
`}
` : ''}
</div>
`;
})}
</div>
` : ''}
</div>
</div>
`;
// <div class="api-key-section">
// <input
// type="password"
// id="api-key-input"
// placeholder="Enter API Key"
// .value=${this.apiKey || ''}
// ?disabled=${loggedIn}
// >
// <button class="settings-button full-width" @click=${this.handleSaveApiKey} ?disabled=${loggedIn}>
// Save API Key
// </button>
// </div>
return html`
<div class="settings-container">
<div class="header-section">
<div>
<h1 class="app-title">Pickle Glass</h1>
<div class="account-info">
${this.firebaseUser
? html`Account: ${this.firebaseUser.email || 'Logged In'}`
: `Account: Not Logged In`
}
</div>
</div>
<div class="invisibility-icon ${this.isContentProtectionOn ? 'visible' : ''}" title="Invisibility is On">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.785 7.41787C8.7 7.41787 7.79 8.19371 7.55667 9.22621C7.0025 8.98704 6.495 9.05121 6.11 9.22037C5.87083 8.18204 4.96083 7.41787 3.88167 7.41787C2.61583 7.41787 1.58333 8.46204 1.58333 9.75121C1.58333 11.0404 2.61583 12.0845 3.88167 12.0845C5.08333 12.0845 6.06333 11.1395 6.15667 9.93787C6.355 9.79787 6.87417 9.53537 7.51 9.94954C7.615 11.1454 8.58333 12.0845 9.785 12.0845C11.0508 12.0845 12.0833 11.0404 12.0833 9.75121C12.0833 8.46204 11.0508 7.41787 9.785 7.41787ZM3.88167 11.4195C2.97167 11.4195 2.2425 10.6729 2.2425 9.75121C2.2425 8.82954 2.9775 8.08287 3.88167 8.08287C4.79167 8.08287 5.52083 8.82954 5.52083 9.75121C5.52083 10.6729 4.79167 11.4195 3.88167 11.4195ZM9.785 11.4195C8.875 11.4195 8.14583 10.6729 8.14583 9.75121C8.14583 8.82954 8.875 8.08287 9.785 8.08287C10.695 8.08287 11.43 8.82954 11.43 9.75121C11.43 10.6729 10.6892 11.4195 9.785 11.4195ZM12.6667 5.95954H1V6.83454H12.6667V5.95954ZM8.8925 1.36871C8.76417 1.08287 8.4375 0.931207 8.12833 1.03037L6.83333 1.46204L5.5325 1.03037L5.50333 1.02454C5.19417 0.93704 4.8675 1.10037 4.75083 1.39787L3.33333 5.08454H10.3333L8.91 1.39787L8.8925 1.36871Z" fill="white"/>
</svg>
</div>
</div>
// <div class="shortcuts-section">
// ${this.getMainShortcuts().map(shortcut => html`
// <div class="shortcut-item">
// <span class="shortcut-name">${shortcut.name}</span>
// <div class="shortcut-keys">
// <span class="cmd-key">⌘</span>
// <span class="shortcut-key">${shortcut.key}</span>
// </div>
// </div>
// `)}
// </div>
${apiKeyManagementHTML}
${modelSelectionHTML}
// <!-- Preset Management Section -->
// <div class="preset-section">
// <div class="preset-header">
// <span class="preset-title">
// My Presets
// <span class="preset-count">(${this.presets.filter(p => p.is_default === 0).length})</span>
// </span>
// <span class="preset-toggle" @click=${this.togglePresets}>
// ${this.showPresets ? '▼' : '▶'}
// </span>
// </div>
<div class="buttons-section" style="border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 6px; margin-top: 6px;">
<button class="settings-button full-width" @click=${this.openShortcutEditor}>
Edit Shortcuts
</button>
</div>
<div class="shortcuts-section">
${this.getMainShortcuts().map(shortcut => html`
<div class="shortcut-item">
<span class="shortcut-name">${shortcut.name}</span>
<div class="shortcut-keys">
${this.renderShortcutKeys(shortcut.accelerator)}
</div>
</div>
`)}
</div>
<div class="preset-section">
<div class="preset-header">
<span class="preset-title">
My Presets
<span class="preset-count">(${this.presets.filter(p => p.is_default === 0).length})</span>
</span>
<span class="preset-toggle" @click=${this.togglePresets}>
${this.showPresets ? '▼' : '▶'}
</span>
</div>
// <div class="preset-list ${this.showPresets ? '' : 'hidden'}">
// ${this.presets.filter(p => p.is_default === 0).length === 0 ? html`
// <div class="no-presets-message">
// No custom presets yet.<br>
// <span class="web-link" @click=${this.handlePersonalize}>
// Create your first preset
// </span>
// </div>
// ` : this.presets.filter(p => p.is_default === 0).map(preset => html`
// <div class="preset-item ${this.selectedPreset?.id === preset.id ? 'selected' : ''}"
// @click=${() => this.handlePresetSelect(preset)}>
// <span class="preset-name">${preset.title}</span>
// ${this.selectedPreset?.id === preset.id ? html`<span class="preset-status">Selected</span>` : ''}
// </div>
// `)}
// </div>
// </div>
<div class="preset-list ${this.showPresets ? '' : 'hidden'}">
${this.presets.filter(p => p.is_default === 0).length === 0 ? html`
<div class="no-presets-message">
No custom presets yet.<br>
<span class="web-link" @click=${this.handlePersonalize}>
Create your first preset
</span>
</div>
` : this.presets.filter(p => p.is_default === 0).map(preset => html`
<div class="preset-item ${this.selectedPreset?.id === preset.id ? 'selected' : ''}"
@click=${() => this.handlePresetSelect(preset)}>
<span class="preset-name">${preset.title}</span>
${this.selectedPreset?.id === preset.id ? html`<span class="preset-status">Selected</span>` : ''}
</div>
`)}
</div>
</div>
// <div class="buttons-section">
// <button class="settings-button full-width" @click=${this.handlePersonalize}>
// <span>Personalize / Meeting Notes</span>
// </button>
<div class="buttons-section">
<button class="settings-button full-width" @click=${this.handlePersonalize}>
<span>Personalize / Meeting Notes</span>
</button>
<button class="settings-button full-width" @click=${this.handleToggleAutoUpdate} ?disabled=${this.autoUpdateLoading}>
<span>Automatic Updates: ${this.autoUpdateEnabled ? 'On' : 'Off'}</span>
</button>
// <div class="move-buttons">
// <button class="settings-button half-width" @click=${this.handleMoveLeft}>
// <span>← Move</span>
// </button>
// <button class="settings-button half-width" @click=${this.handleMoveRight}>
// <span>Move →</span>
// </button>
// </div>
<div class="move-buttons">
<button class="settings-button half-width" @click=${this.handleMoveLeft}>
<span> Move</span>
</button>
<button class="settings-button half-width" @click=${this.handleMoveRight}>
<span>Move </span>
</button>
</div>
// <button class="settings-button full-width" @click=${this.handleToggleInvisibility}>
// <span>${this.isContentProtectionOn ? 'Disable Invisibility' : 'Enable Invisibility'}</span>
// </button>
<button class="settings-button full-width" @click=${this.handleToggleInvisibility}>
<span>${this.isContentProtectionOn ? 'Disable Invisibility' : 'Enable Invisibility'}</span>
</button>
// <div class="bottom-buttons">
// ${this.firebaseUser
// ? html`
// <button class="settings-button half-width danger" @click=${this.handleFirebaseLogout}>
// <span>Logout</span>
// </button>
// `
// : html`
// <button class="settings-button half-width danger" @click=${this.handleClearApiKey}>
// <span>Clear API Key</span>
// </button>
// `
// }
// <button class="settings-button half-width danger" @click=${this.handleQuit}>
// <span>Quit</span>
// </button>
// </div>
// </div>
// </div>
// `;
// }
//////// before_modelStateService ////////
<div class="bottom-buttons">
${this.firebaseUser
? html`
<button class="settings-button half-width danger" @click=${this.handleFirebaseLogout}>
<span>Logout</span>
</button>
`
: html`
<button class="settings-button half-width" @click=${this.handleUsePicklesKey}>
<span>Login</span>
</button>
`
}
<button class="settings-button half-width danger" @click=${this.handleQuit}>
<span>Quit</span>
</button>
</div>
</div>
</div>
`;
}
//////// after_modelStateService ////////
}

View File

@ -102,23 +102,22 @@ export class ShortcutSettingsView extends LitElement {
this.feedback = {};
this.isLoading = true;
this.capturingKey = null;
this.hasAPI = window.api && window.api.settings;
}
connectedCallback() {
super.connectedCallback();
if (!this.hasAPI) return;
if (!window.api) return;
this.loadShortcutsHandler = (event, keybinds) => {
this.shortcuts = keybinds;
this.isLoading = false;
};
window.api.settings.onLoadShortcuts(this.loadShortcutsHandler);
window.api.shortcutSettingsView.onLoadShortcuts(this.loadShortcutsHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.hasAPI && this.loadShortcutsHandler) {
window.api.settings.removeOnLoadShortcuts(this.loadShortcutsHandler);
if (window.api && this.loadShortcutsHandler) {
window.api.shortcutSettingsView.removeOnLoadShortcuts(this.loadShortcutsHandler);
}
}
@ -171,25 +170,27 @@ export class ShortcutSettingsView extends LitElement {
}
async handleSave() {
if (!this.hasAPI) return;
const result = await window.api.settings.saveShortcuts(this.shortcuts);
if (!window.api) return;
this.feedback = {};
const result = await window.api.shortcutSettingsView.saveShortcuts(this.shortcuts);
if (!result.success) {
alert('Failed to save shortcuts: ' + result.error);
}
}
handleClose() {
if (!this.hasAPI) return;
window.api.settings.closeShortcutEditor();
if (!window.api) return;
this.feedback = {};
window.api.shortcutSettingsView.closeShortcutSettingsWindow();
}
async handleResetToDefault() {
if (!this.hasAPI) return;
if (!window.api) return;
const confirmation = confirm("Are you sure you want to reset all shortcuts to their default values?");
if (!confirmation) return;
try {
const defaultShortcuts = await window.api.settings.getDefaultShortcuts();
const defaultShortcuts = await window.api.shortcutSettingsView.getDefaultShortcuts();
this.shortcuts = defaultShortcuts;
} catch (error) {
alert('Failed to load default settings.');

View File

@ -1,11 +1,8 @@
const { screen } = require('electron');
class SmoothMovementManager {
constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
constructor(windowPool) {
this.windowPool = windowPool;
this.getDisplayById = getDisplayById;
this.getCurrentDisplay = getCurrentDisplay;
this.updateLayout = updateLayout;
this.stepSize = 80;
this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 };
@ -14,6 +11,8 @@ class SmoothMovementManager {
this.lastVisiblePosition = null;
this.currentDisplayId = null;
this.animationFrameId = null;
this.animationTimers = new Map();
}
/**
@ -22,248 +21,162 @@ class SmoothMovementManager {
*/
_isWindowValid(win) {
if (!win || win.isDestroyed()) {
if (this.isAnimating) {
console.warn('[MovementManager] Window destroyed mid-animation. Halting.');
this.isAnimating = false;
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
// 해당 창의 타이머가 있으면 정리
if (this.animationTimers.has(win)) {
clearTimeout(this.animationTimers.get(win));
this.animationTimers.delete(win);
}
return false;
}
return true;
}
moveToDisplay(displayId) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const targetDisplay = this.getDisplayById(displayId);
if (!targetDisplay) return;
const currentBounds = header.getBounds();
const currentDisplay = this.getCurrentDisplay(header);
if (currentDisplay.id === targetDisplay.id) return;
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, finalX, finalY);
this.currentDisplayId = targetDisplay.id;
}
hideToEdge(edge, callback, { instant = false } = {}) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
const { x, y } = header.getBounds();
this.lastVisiblePosition = { x, y };
this.hiddenPosition = { edge };
if (instant) {
header.hide();
if (typeof callback === 'function') callback();
/**
*
* @param {BrowserWindow} win
* @param {number} targetX
* @param {number} targetY
* @param {object} [options]
* @param {object} [options.sizeOverride]
* @param {function} [options.onComplete]
* @param {number} [options.duration]
*/
animateWindow(win, targetX, targetY, options = {}) {
if (!this._isWindowValid(win)) {
if (options.onComplete) options.onComplete();
return;
}
header.webContents.send('window-hide-animation');
setTimeout(() => {
if (!header.isDestroyed()) header.hide();
if (typeof callback === 'function') callback();
}, 5);
}
showFromEdge(callback) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
// 숨기기 전에 기억해둔 위치 복구
if (this.lastVisiblePosition) {
header.setPosition(
this.lastVisiblePosition.x,
this.lastVisiblePosition.y,
false // animate: false
);
}
header.show();
header.webContents.send('window-show-animation');
// 내부 상태 초기화
this.hiddenPosition = null;
this.lastVisiblePosition = null;
if (typeof callback === 'function') callback();
}
moveStep(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const currentBounds = header.getBounds();
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
console.log(`[MovementManager] Moving ${direction} from (${targetX}, ${targetY})`);
const windowSize = {
width: currentBounds.width,
height: currentBounds.height
};
switch (direction) {
case 'left': targetX -= this.stepSize; break;
case 'right': targetX += this.stepSize; break;
case 'up': targetY -= this.stepSize; break;
case 'down': targetY += this.stepSize; break;
default: return;
}
// Find the display that contains or is nearest to the target position
const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
const { x: workAreaX, y: workAreaY, width: workAreaWidth, height: workAreaHeight } = nearestDisplay.workArea;
// Only clamp if the target position would actually go out of bounds
let clampedX = targetX;
let clampedY = targetY;
// Check horizontal bounds
if (targetX < workAreaX) {
clampedX = workAreaX;
} else if (targetX + currentBounds.width > workAreaX + workAreaWidth) {
clampedX = workAreaX + workAreaWidth - currentBounds.width;
}
// Check vertical bounds
if (targetY < workAreaY) {
clampedY = workAreaY;
console.log(`[MovementManager] Clamped Y to top edge: ${clampedY}`);
} else if (targetY + currentBounds.height > workAreaY + workAreaHeight) {
clampedY = workAreaY + workAreaHeight - currentBounds.height;
console.log(`[MovementManager] Clamped Y to bottom edge: ${clampedY}`);
}
console.log(`[MovementManager] Final position: (${clampedX}, ${clampedY}), Work area: ${workAreaX},${workAreaY} ${workAreaWidth}x${workAreaHeight}`);
// Only move if there's an actual change in position
if (clampedX === this.headerPosition.x && clampedY === this.headerPosition.y) {
console.log(`[MovementManager] No position change, skipping animation`);
return;
}
this.animateToPosition(header, clampedX, clampedY, windowSize);
}
animateToPosition(header, targetX, targetY, windowSize) {
if (!this._isWindowValid(header)) return;
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const { sizeOverride, onComplete, duration: animDuration } = options;
const start = win.getBounds();
const startTime = Date.now();
const duration = animDuration || this.animationDuration;
const { width, height } = sizeOverride || start;
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
this.isAnimating = false;
return;
}
const animate = () => {
if (!this._isWindowValid(header)) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / this.animationDuration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
this.isAnimating = false;
const step = () => {
if (!this._isWindowValid(win)) {
if (onComplete) onComplete();
return;
}
if (!this._isWindowValid(header)) return;
const { width, height } = windowSize || header.getBounds();
header.setBounds({
x: Math.round(currentX),
y: Math.round(currentY),
width,
height
});
const p = Math.min((Date.now() - startTime) / duration, 1);
const eased = 1 - Math.pow(1 - p, 3); // ease-out-cubic
const x = start.x + (targetX - start.x) * eased;
const y = start.y + (targetY - start.y) * eased;
if (progress < 1) {
this.animationFrameId = setTimeout(animate, 8);
win.setBounds({ x: Math.round(x), y: Math.round(y), width, height });
if (p < 1) {
setTimeout(step, 8);
} else {
this.animationFrameId = null;
this.isAnimating = false;
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(targetX), Math.round(targetY));
// Update header position to the actual final position
this.headerPosition = { x: Math.round(targetX), y: Math.round(targetY) };
this.layoutManager.updateLayout();
if (onComplete) {
onComplete();
}
this.updateLayout();
}
};
animate();
step();
}
moveToEdge(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const display = this.getCurrentDisplay(header);
const { width, height } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const currentBounds = header.getBounds();
fade(win, { from, to, duration = 250, onComplete }) {
if (!this._isWindowValid(win)) {
if (onComplete) onComplete();
return;
}
const startOpacity = from ?? win.getOpacity();
const startTime = Date.now();
const windowSize = {
width: currentBounds.width,
height: currentBounds.height
const step = () => {
if (!this._isWindowValid(win)) {
if (onComplete) onComplete(); return;
}
const progress = Math.min(1, (Date.now() - startTime) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
win.setOpacity(startOpacity + (to - startOpacity) * eased);
if (progress < 1) {
setTimeout(step, 8);
} else {
win.setOpacity(to);
if (onComplete) onComplete();
}
};
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left':
targetX = workAreaX;
break;
case 'right':
targetX = workAreaX + width - windowSize.width;
break;
case 'up':
targetY = workAreaY;
break;
case 'down':
targetY = workAreaY + height - windowSize.height;
break;
step();
}
animateWindowBounds(win, targetBounds, options = {}) {
if (this.animationTimers.has(win)) {
clearTimeout(this.animationTimers.get(win));
}
header.setBounds({
x: Math.round(targetX),
y: Math.round(targetY),
width: windowSize.width,
height: windowSize.height
});
if (!this._isWindowValid(win)) {
if (options.onComplete) options.onComplete();
return;
}
this.headerPosition = { x: targetX, y: targetY };
this.updateLayout();
this.isAnimating = true;
const startBounds = win.getBounds();
const startTime = Date.now();
const duration = options.duration || this.animationDuration;
const step = () => {
if (!this._isWindowValid(win)) {
if (options.onComplete) options.onComplete();
return;
}
const progress = Math.min(1, (Date.now() - startTime) / duration);
const eased = 1 - Math.pow(1 - progress, 3);
const newBounds = {
x: Math.round(startBounds.x + (targetBounds.x - startBounds.x) * eased),
y: Math.round(startBounds.y + (targetBounds.y - startBounds.y) * eased),
width: Math.round(startBounds.width + ((targetBounds.width ?? startBounds.width) - startBounds.width) * eased),
height: Math.round(startBounds.height + ((targetBounds.height ?? startBounds.height) - startBounds.height) * eased),
};
win.setBounds(newBounds);
if (progress < 1) {
const timerId = setTimeout(step, 8);
this.animationTimers.set(win, timerId);
} else {
win.setBounds(targetBounds);
this.animationTimers.delete(win);
if (this.animationTimers.size === 0) {
this.isAnimating = false;
}
if (options.onComplete) options.onComplete();
}
};
step();
}
animateWindowPosition(win, targetPosition, options = {}) {
if (!this._isWindowValid(win)) {
if (options.onComplete) options.onComplete();
return;
}
const currentBounds = win.getBounds();
const targetBounds = { ...currentBounds, ...targetPosition };
this.animateWindowBounds(win, targetBounds, options);
}
animateLayout(layout, animated = true) {
if (!layout) return;
for (const winName in layout) {
const win = this.windowPool.get(winName);
const targetBounds = layout[winName];
if (win && !win.isDestroyed() && targetBounds) {
if (animated) {
this.animateWindowBounds(win, targetBounds);
} else {
win.setBounds(targetBounds);
}
}
}
}
destroy() {

View File

@ -1,9 +1,9 @@
const { screen } = require('electron');
/**
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다.
* @param {BrowserWindow} window - 확인할 객체
* @returns {Display} Electron의 Display 객체
*
* @param {BrowserWindow} window
* @returns {Display}
*/
function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
@ -27,53 +27,28 @@ class WindowLayoutManager {
this.PADDING = 80;
}
/**
* 모든 창의 레이아웃 업데이트를 요청합니다.
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
*/
updateLayout() {
if (this.isUpdating) return;
this.isUpdating = true;
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
/**
* 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
*/
positionWindows() {
getHeaderPosition = () => {
const header = this.windowPool.get('header');
if (!header?.getBounds) return;
if (header) {
const [x, y] = header.getPosition();
return { x, y };
}
return { x: 0, y: 0 };
};
const headerBounds = header.getBounds();
const display = getCurrentDisplay(header);
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
const relativeX = headerCenterX / screenWidth;
const relativeY = headerCenterY / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY);
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
}
/**
* 헤더 창의 위치에 따라 기능 창들을 배치할 최적의 전략을 결정합니다.
* @returns {{name: string, primary: string, secondary: string}} 레이아웃 전략
*
* @returns {{name: string, primary: string, secondary: string}}
*/
determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY) {
const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height);
const spaceAbove = headerBounds.y;
const spaceLeft = headerBounds.x;
const spaceRight = screenWidth - (headerBounds.x + headerBounds.width);
determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY) {
const headerRelX = headerBounds.x - workAreaX;
const headerRelY = headerBounds.y - workAreaY;
const spaceBelow = screenHeight - (headerRelY + headerBounds.height);
const spaceAbove = headerRelY;
const spaceLeft = headerRelX;
const spaceRight = screenWidth - (headerRelX + headerBounds.width);
if (spaceBelow >= 400) {
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' };
@ -88,120 +63,242 @@ class WindowLayoutManager {
}
}
/**
* 'ask' 'listen' 창의 위치를 조정합니다.
* @returns {{x: number, y: number} | null}
*/
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
calculateSettingsWindowPosition() {
const header = this.windowPool.get('header');
const settings = this.windowPool.get('settings');
if (!askVisible && !listenVisible) return;
const PAD = 8;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
let askBounds = askVisible ? ask.getBounds() : null;
let listenBounds = listenVisible ? listen.getBounds() : null;
if (askVisible && listenVisible) {
const combinedWidth = listenBounds.width + PAD + askBounds.width;
let groupStartXRel = headerCenterXRel - combinedWidth / 2;
let listenXRel = groupStartXRel;
let askXRel = groupStartXRel + listenBounds.width + PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenBounds.width + PAD;
}
if (askXRel + askBounds.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askBounds.width;
listenXRel = askXRel - listenBounds.width - PAD;
}
let yRel = (strategy.primary === 'above')
? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD;
listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yRel + workAreaY), width: listenBounds.width, height: listenBounds.height });
ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yRel + workAreaY), width: askBounds.width, height: askBounds.height });
} else {
const win = askVisible ? ask : listen;
const winBounds = askVisible ? askBounds : listenBounds;
let xRel = headerCenterXRel - winBounds.width / 2;
let yRel = (strategy.primary === 'above')
? headerBounds.y - workAreaY - winBounds.height - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD;
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), width: winBounds.width, height: winBounds.height });
if (!header || header.isDestroyed() || !settings || settings.isDestroyed()) {
return null;
}
const headerBounds = header.getBounds();
const settingsBounds = settings.getBounds();
const display = getCurrentDisplay(header);
const { x: workAreaX, y: workAreaY, width: screenWidth, height: screenHeight } = display.workArea;
const PAD = 5;
const buttonPadding = 170;
const x = headerBounds.x + headerBounds.width - settingsBounds.width + buttonPadding;
const y = headerBounds.y + headerBounds.height + PAD;
const clampedX = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
const clampedY = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
return { x: Math.round(clampedX), y: Math.round(clampedY) };
}
/**
* 'settings' 창의 위치를 조정합니다.
*/
positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const settings = this.windowPool.get('settings');
if (!settings?.getBounds || !settings.isVisible()) return;
if (settings.__lockedByButton) {
const headerDisplay = getCurrentDisplay(this.windowPool.get('header'));
const settingsDisplay = getCurrentDisplay(settings);
if (headerDisplay.id !== settingsDisplay.id) {
settings.__lockedByButton = false;
} else {
return;
}
calculateHeaderResize(header, { width, height }) {
if (!header) return null;
const currentBounds = header.getBounds();
const centerX = currentBounds.x + currentBounds.width / 2;
const newX = Math.round(centerX - width / 2);
const display = getCurrentDisplay(header);
const { x: workAreaX, width: workAreaWidth } = display.workArea;
const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX));
return { x: clampedX, y: currentBounds.y, width, height };
}
calculateClampedPosition(header, { x: newX, y: newY }) {
if (!header) return null;
const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
const headerBounds = header.getBounds();
const clampedX = Math.max(workAreaX, Math.min(newX, workAreaX + width - headerBounds.width));
const clampedY = Math.max(workAreaY, Math.min(newY, workAreaY + height - headerBounds.height));
return { x: clampedX, y: clampedY };
}
calculateWindowHeightAdjustment(senderWindow, targetHeight) {
if (!senderWindow) return null;
const currentBounds = senderWindow.getBounds();
const minHeight = senderWindow.getMinimumSize()[1];
const maxHeight = senderWindow.getMaximumSize()[1];
let adjustedHeight = Math.max(minHeight, targetHeight);
if (maxHeight > 0) {
adjustedHeight = Math.min(maxHeight, adjustedHeight);
}
console.log(`[Layout Debug] calculateWindowHeightAdjustment: targetHeight=${targetHeight}`);
return { ...currentBounds, height: adjustedHeight };
}
// 기존 getTargetBoundsForFeatureWindows를 이 함수로 대체합니다.
calculateFeatureWindowLayout(visibility, headerBoundsOverride = null) {
const header = this.windowPool.get('header');
const headerBounds = headerBoundsOverride || (header ? header.getBounds() : null);
const settingsBounds = settings.getBounds();
const PAD = 5;
const buttonPadding = 17;
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
let y = headerBounds.y + headerBounds.height + PAD;
if (!headerBounds) return {};
const otherVisibleWindows = [];
['listen', 'ask'].forEach(name => {
const win = this.windowPool.get(name);
if (win && win.isVisible() && !win.isDestroyed()) {
otherVisibleWindows.push({ name, bounds: win.getBounds() });
}
});
const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height };
let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds));
if (hasOverlap) {
x = headerBounds.x + headerBounds.width + PAD;
y = headerBounds.y;
if (x + settingsBounds.width > screenWidth - 10) {
x = headerBounds.x - settingsBounds.width - PAD;
}
if (x < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
y = headerBounds.y - settingsBounds.height - PAD;
if (y < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width;
y = headerBounds.y + headerBounds.height + PAD;
}
}
let display;
if (headerBoundsOverride) {
const boundsCenter = {
x: headerBounds.x + headerBounds.width / 2,
y: headerBounds.y + headerBounds.height / 2,
};
display = screen.getDisplayNearestPoint(boundsCenter);
} else {
display = getCurrentDisplay(header);
}
const { width: screenWidth, height: screenHeight, x: workAreaX, y: workAreaY } = display.workArea;
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVis = visibility.ask && ask && !ask.isDestroyed();
const listenVis = visibility.listen && listen && !listen.isDestroyed();
if (!askVis && !listenVis) return {};
const PAD = 8;
const headerTopRel = headerBounds.y - workAreaY;
const headerBottomRel = headerTopRel + headerBounds.height;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
const relativeX = headerCenterXRel / screenWidth;
const relativeY = (headerBounds.y - workAreaY) / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY);
const askB = askVis ? ask.getBounds() : null;
const listenB = listenVis ? listen.getBounds() : null;
x = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
if (askVis) {
console.log(`[Layout Debug] Ask Window Bounds: height=${askB.height}, width=${askB.width}`);
}
if (listenVis) {
console.log(`[Layout Debug] Listen Window Bounds: height=${listenB.height}, width=${listenB.width}`);
}
const layout = {};
if (askVis && listenVis) {
let askXRel = headerCenterXRel - (askB.width / 2);
let listenXRel = askXRel - listenB.width - PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenB.width + PAD;
}
if (askXRel + askB.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askB.width;
listenXRel = askXRel - listenB.width - PAD;
}
if (strategy.primary === 'above') {
const windowBottomAbs = headerBounds.y - PAD;
layout.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(windowBottomAbs - askB.height), width: askB.width, height: askB.height };
layout.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(windowBottomAbs - listenB.height), width: listenB.width, height: listenB.height };
} else { // 'below'
const yAbs = headerBounds.y + headerBounds.height + PAD;
layout.ask = { x: Math.round(askXRel + workAreaX), y: Math.round(yAbs), width: askB.width, height: askB.height };
layout.listen = { x: Math.round(listenXRel + workAreaX), y: Math.round(yAbs), width: listenB.width, height: listenB.height };
}
} else { // Single window
const winName = askVis ? 'ask' : 'listen';
const winB = askVis ? askB : listenB;
if (!winB) return {};
let xRel = headerCenterXRel - winB.width / 2;
xRel = Math.max(PAD, Math.min(screenWidth - winB.width - PAD, xRel));
let yPos;
if (strategy.primary === 'above') {
yPos = (headerBounds.y - workAreaY) - PAD - winB.height;
} else { // 'below'
yPos = (headerBounds.y - workAreaY) + headerBounds.height + PAD;
}
layout[winName] = { x: Math.round(xRel + workAreaX), y: Math.round(yPos + workAreaY), width: winB.width, height: winB.height };
}
return layout;
}
calculateShortcutSettingsWindowPosition() {
const header = this.windowPool.get('header');
const shortcutSettings = this.windowPool.get('shortcut-settings');
if (!header || !shortcutSettings) return null;
const headerBounds = header.getBounds();
const shortcutBounds = shortcutSettings.getBounds();
const { workArea } = getCurrentDisplay(header);
let newX = Math.round(headerBounds.x + (headerBounds.width / 2) - (shortcutBounds.width / 2));
let newY = Math.round(headerBounds.y);
newX = Math.max(workArea.x, Math.min(newX, workArea.x + workArea.width - shortcutBounds.width));
newY = Math.max(workArea.y, Math.min(newY, workArea.y + workArea.height - shortcutBounds.height));
return { x: newX, y: newY, width: shortcutBounds.width, height: shortcutBounds.height };
}
settings.setBounds({ x: Math.round(x), y: Math.round(y) });
settings.moveTop();
calculateStepMovePosition(header, direction) {
if (!header) return null;
const currentBounds = header.getBounds();
const stepSize = 80; // 이동 간격
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left': targetX -= stepSize; break;
case 'right': targetX += stepSize; break;
case 'up': targetY -= stepSize; break;
case 'down': targetY += stepSize; break;
}
return this.calculateClampedPosition(header, { x: targetX, y: targetY });
}
calculateEdgePosition(header, direction) {
if (!header) return null;
const display = getCurrentDisplay(header);
const { workArea } = display;
const currentBounds = header.getBounds();
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left': targetX = workArea.x; break;
case 'right': targetX = workArea.x + workArea.width - currentBounds.width; break;
case 'up': targetY = workArea.y; break;
case 'down': targetY = workArea.y + workArea.height - currentBounds.height; break;
}
return { x: targetX, y: targetY };
}
calculateNewPositionForDisplay(window, targetDisplayId) {
if (!window) return null;
const targetDisplay = screen.getAllDisplays().find(d => d.id === targetDisplayId);
if (!targetDisplay) return null;
const currentBounds = window.getBounds();
const currentDisplay = getCurrentDisplay(window);
if (currentDisplay.id === targetDisplay.id) return { x: currentBounds.x, y: currentBounds.y };
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workArea.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workArea.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workArea.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workArea.height * relativeY;
const clampedX = Math.max(targetDisplay.workArea.x, Math.min(targetX, targetDisplay.workArea.x + targetDisplay.workArea.width - currentBounds.width));
const clampedY = Math.max(targetDisplay.workArea.y, Math.min(targetY, targetDisplay.workArea.y + targetDisplay.workArea.height - currentBounds.height));
return { x: Math.round(clampedX), y: Math.round(clampedY) };
}
/**
* 사각형 영역이 겹치는지 확인합니다.
* @param {Rectangle} bounds1
* @param {Rectangle} bounds2
* @returns {boolean} 겹침 여부
* @returns {boolean}
*/
boundsOverlap(bounds1, bounds2) {
const margin = 10;

File diff suppressed because it is too large Load Diff