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

78
package-lock.json generated
View File

@ -11,6 +11,7 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.56.0", "@anthropic-ai/sdk": "^0.56.0",
"@deepgram/sdk": "^4.9.1",
"@google/genai": "^1.8.0", "@google/genai": "^1.8.0",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"axios": "^1.10.0", "axios": "^1.10.0",
@ -54,6 +55,50 @@
"anthropic-ai-sdk": "bin/cli" "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": { "node_modules/@develar/schema-utils": {
"version": "2.6.5", "version": "2.6.5",
"dev": true, "dev": true,
@ -2992,6 +3037,15 @@
"optional": true, "optional": true,
"peer": 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": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"dev": true, "dev": true,
@ -3020,6 +3074,12 @@
"node": ">=6" "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": { "node_modules/debounce-fn": {
"version": "4.0.0", "version": "4.0.0",
"license": "MIT", "license": "MIT",
@ -3078,6 +3138,15 @@
"node": ">=4.0.0" "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": { "node_modules/defaults": {
"version": "1.0.4", "version": "1.0.4",
"dev": true, "dev": true,
@ -3735,6 +3804,15 @@
"node": ">=6" "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": { "node_modules/expand-template": {
"version": "2.0.3", "version": "2.0.3",
"license": "(MIT OR WTFPL)", "license": "(MIT OR WTFPL)",

View File

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

View File

@ -1,73 +1,235 @@
// src/bridge/featureBridge.js // src/bridge/featureBridge.js
const { ipcMain } = require('electron'); const { ipcMain, app, BrowserWindow } = require('electron');
const settingsService = require('../features/settings/settingsService'); 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 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 = { module.exports = {
// Renderer로부터의 요청을 수신 // Renderer로부터의 요청을 수신하고 서비스로 전달
initialize() { initialize() {
// Ask 관련 핸들러 추가 // Settings Service
ipcMain.handle('ask:sendMessage', async (event, userPrompt) => { ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
return askService.sendMessage(userPrompt); 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 핸들러 유지 // User/Auth
ipcMain.handle('feature:ask', (e, query) => { ipcMain.handle('get-current-user', () => authService.getCurrentUser());
// 실제로는 여기서 Controller -> Service 로직 수행 ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow());
return `"${query}"에 대한 답변입니다.`; 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 관련 핸들러 추가 // ModelStateService
ipcMain.handle('settings:getSettings', async () => { ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
return await settingsService.getSettings(); 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);
}
});
}); });
localAIManager.on('installation-complete', (service) => {
ipcMain.handle('settings:saveSettings', async (event, settings) => { BrowserWindow.getAllWindows().forEach(win => {
return await settingsService.saveSettings(settings); if (win && !win.isDestroyed()) {
win.webContents.send('localai:installation-complete', { service });
}
});
}); });
localAIManager.on('error', (error) => {
ipcMain.handle('settings:getPresets', async () => { BrowserWindow.getAllWindows().forEach(win => {
return await settingsService.getPresets(); if (win && !win.isDestroyed()) {
win.webContents.send('localai:error-occurred', error);
}
});
}); });
// Handle error-occurred events from LocalAIManager's error handling
ipcMain.handle('settings:getPresetTemplates', async () => { localAIManager.on('error-occurred', (error) => {
return await settingsService.getPresetTemplates(); BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:error-occurred', error);
}
});
}); });
localAIManager.on('model-ready', (data) => {
ipcMain.handle('settings:createPreset', async (event, title, prompt) => { BrowserWindow.getAllWindows().forEach(win => {
return await settingsService.createPreset(title, prompt); if (win && !win.isDestroyed()) {
win.webContents.send('localai:model-ready', data);
}
});
}); });
localAIManager.on('state-changed', (service, state) => {
ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => { const event = { service, ...state };
return await settingsService.updatePreset(id, title, prompt); BrowserWindow.getAllWindows().forEach(win => {
}); if (win && !win.isDestroyed()) {
win.webContents.send('localai:service-status-changed', event);
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);
}); });
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) => { // LocalAI 통합 핸들러 추가
console.log('[SettingsService] Setting auto update setting:', isEnabled); ipcMain.handle('localai:install', async (event, { service, options }) => {
return await settingsService.setAutoUpdateSetting(isEnabled); 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로 상태를 전송 // Renderer로 상태를 전송

View File

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

View File

@ -1,74 +1,34 @@
// src/bridge/windowBridge.js // src/bridge/windowBridge.js
const { ipcMain, BrowserWindow } = require('electron'); const { ipcMain, shell } = require('electron');
const { windowPool, settingsHideTimer, app, shell } = require('../window/windowManager'); // 필요 변수 require
// Bridge는 단순히 IPC 핸들러를 등록하는 역할만 함 (비즈니스 로직 없음)
module.exports = { module.exports = {
// Renderer로부터의 요청을 수신
initialize() { initialize() {
// 기존 // initialize 시점에 windowManager를 require하여 circular dependency 문제 해결
ipcMain.on('window:hide', (e) => BrowserWindow.fromWebContents(e.sender)?.hide()); 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('open-login-page', () => windowManager.openLoginPage());
ipcMain.handle('toggle-content-protection', () => { ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
// windowManager의 toggle-content-protection 로직 ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
isContentProtectionOn = !isContentProtectionOn; ipcMain.handle('open-external', (event, url) => shell.openExternal(url));
windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
win.setContentProtection(isContentProtectionOn);
}
});
return isContentProtectionOn;
});
ipcMain.handle('get-content-protection-status', () => { // Newly moved handlers from windowManager
return isContentProtectionOn; 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('open-shortcut-editor', () => { ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
// open-shortcut-editor 로직 (windowPool 등 필요시 require) ipcMain.handle('adjust-window-height', (event, { winName, height }) => windowManager.adjustWindowHeight(winName, height));
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(...); });
}, },
// Renderer로 상태를 전송
notifyFocusChange(win, isFocused) { notifyFocusChange(win, isFocused) {
win.webContents.send('window:focus-change', 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 { createStreamingLLM } = require('../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../window/windowManager'); // Lazy require helper to avoid circular dependency issues
const authService = require('../common/services/authService'); 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 sessionRepository = require('../common/repositories/session');
const askRepository = require('./repositories'); const askRepository = require('./repositories');
const { getSystemPrompt } = require('../common/prompts/promptBuilder'); const { getSystemPrompt } = require('../common/prompts/promptBuilder');
const path = require('node:path');
const fs = require('node:fs');
const os = require('os');
const util = require('util');
const execFile = util.promisify(require('child_process').execFile);
const { desktopCapturer } = require('electron');
const modelStateService = require('../common/services/modelStateService');
function formatConversationForPrompt(conversationTexts) { // Try to load sharp, but don't fail if it's not available
if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.'; let sharp;
return conversationTexts.slice(-30).join('\n'); try {
sharp = require('sharp');
console.log('[AskService] Sharp module loaded successfully');
} catch (error) {
console.warn('[AskService] Sharp module not available:', error.message);
console.warn('[AskService] Screenshot functionality will work with reduced image processing capabilities');
sharp = null;
} }
let lastScreenshot = null;
// Access conversation history via the global listenService instance created in index.js async function captureScreenshot(options = {}) {
function getConversationHistory() { if (process.platform === 'darwin') {
const listenService = global.listenService; try {
return listenService ? listenService.getConversationHistory() : []; const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`);
}
async function sendMessage(userPrompt) { await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]);
if (!userPrompt || userPrompt.trim().length === 0) {
console.warn('[AskService] Cannot process empty message'); const imageBuffer = await fs.promises.readFile(tempPath);
return { success: false, error: 'Empty message' }; await fs.promises.unlink(tempPath);
if (sharp) {
try {
// Try using sharp for optimal image processing
const resizedBuffer = await sharp(imageBuffer)
.resize({ height: 384 })
.jpeg({ quality: 80 })
.toBuffer();
const base64 = resizedBuffer.toString('base64');
const metadata = await sharp(resizedBuffer).metadata();
lastScreenshot = {
base64,
width: metadata.width,
height: metadata.height,
timestamp: Date.now(),
};
return { success: true, base64, width: metadata.width, height: metadata.height };
} catch (sharpError) {
console.warn('Sharp module failed, falling back to basic image processing:', sharpError.message);
}
}
// Fallback: Return the original image without resizing
console.log('[AskService] Using fallback image processing (no resize/compression)');
const base64 = imageBuffer.toString('base64');
lastScreenshot = {
base64,
width: null, // We don't have metadata without sharp
height: null,
timestamp: Date.now(),
};
return { success: true, base64, width: null, height: null };
} catch (error) {
console.error('Failed to capture screenshot:', error);
return { success: false, error: error.message };
}
} }
let sessionId;
try { try {
console.log(`[AskService] Processing message: ${userPrompt.substring(0, 50)}...`); const sources = await desktopCapturer.getSources({
types: ['screen'],
// --- Save user's message immediately --- thumbnailSize: {
// This ensures the user message is always timestamped before the assistant's response. width: 1920,
sessionId = await sessionRepository.getOrCreateActive('ask'); height: 1080,
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
// --- End of user message saving ---
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.');
}
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
const screenshotResult = await captureScreenshot({ quality: 'medium' });
const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
const conversationHistoryRaw = getConversationHistory();
const conversationHistory = formatConversationForPrompt(conversationHistoryRaw);
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
const messages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
],
}, },
];
if (screenshotBase64) {
messages[1].content.push({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
});
}
const streamingLLM = createStreamingLLM(modelInfo.provider, {
apiKey: modelInfo.apiKey,
model: modelInfo.model,
temperature: 0.7,
maxTokens: 2048,
usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
}); });
const response = await streamingLLM.streamChat(messages); if (sources.length === 0) {
throw new Error('No screen sources available');
}
const source = sources[0];
const buffer = source.thumbnail.toJPEG(70);
const base64 = buffer.toString('base64');
const size = source.thumbnail.getSize();
// --- Stream Processing --- return {
const reader = response.body.getReader(); success: true,
const decoder = new TextDecoder(); base64,
let fullResponse = ''; 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()) { * @class
console.error("[AskService] Ask window is not available to send stream to."); * @description
reader.cancel(); */
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; return;
} }
while (true) { const hasContent = this.state.isLoading || this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value); if (askWindow && askWindow.isVisible() && hasContent) {
const lines = chunk.split('\n').filter(line => line.trim() !== ''); 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) { async closeAskWindow () {
if (line.startsWith('data: ')) { if (this.abortController) {
const data = line.substring(6); this.abortController.abort('Window closed by user');
if (data === '[DONE]') { this.abortController = null;
askWin.webContents.send('ask-response-stream-end'); }
// Save assistant's message to DB this.state = {
try { isVisible : false,
// sessionId is already available from when we saved the user prompt isLoading : false,
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); isStreaming : false,
console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`); currentQuestion: '',
} catch(dbError) { currentResponse: '',
console.error("[AskService] DB: Failed to save assistant response:", dbError); 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 fallbackReader = fallbackResponse.body.getReader();
const token = json.choices[0]?.delta?.content || ''; signal.addEventListener('abort', () => {
if (token) { console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`);
fullResponse += token; fallbackReader.cancel(signal.reason).catch(() => {});
askWin.webContents.send('ask-response-chunk', { token }); });
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() { const askService = new AskService();
// IPC 핸들러는 featureBridge.js로 이동됨
console.log('[AskService] Initialized and ready.');
}
module.exports = { module.exports = askService;
initialize,
sendMessage, // sendMessage 함수 export 추가
};

View File

@ -57,6 +57,14 @@ const PROVIDERS = {
], ],
sttModels: [], sttModels: [],
}, },
'deepgram': {
name: 'Deepgram',
handler: () => require("./providers/deepgram"),
llmModels: [],
sttModels: [
{ id: 'nova-3', name: 'Nova-3 (General)' },
],
},
'ollama': { 'ollama': {
name: 'Ollama (Local)', name: 'Ollama (Local)',
handler: () => require("./providers/ollama"), handler: () => require("./providers/ollama"),
@ -68,7 +76,8 @@ const PROVIDERS = {
handler: () => { handler: () => {
// This needs to remain a function due to its conditional logic for renderer/main process // This needs to remain a function due to its conditional logic for renderer/main process
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return require("./providers/whisper"); const { WhisperProvider } = require("./providers/whisper");
return new WhisperProvider();
} }
// Return a dummy object for the renderer process // Return a dummy object for the renderer process
return { return {
@ -147,6 +156,7 @@ function getProviderClass(providerId) {
'openai': 'OpenAIProvider', 'openai': 'OpenAIProvider',
'anthropic': 'AnthropicProvider', 'anthropic': 'AnthropicProvider',
'gemini': 'GeminiProvider', 'gemini': 'GeminiProvider',
'deepgram': 'DeepgramProvider',
'ollama': 'OllamaProvider', 'ollama': 'OllamaProvider',
'whisper': 'WhisperProvider' '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 http = require('http');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
// Request Queue System for Ollama API (only for non-streaming requests)
class RequestQueue {
constructor() {
this.queue = [];
this.processing = false;
this.streamingActive = false;
}
async addStreamingRequest(requestFn) {
// Streaming requests have priority - wait for current processing to finish
while (this.processing) {
await new Promise(resolve => setTimeout(resolve, 50));
}
this.streamingActive = true;
console.log('[Ollama Queue] Starting streaming request (priority)');
try {
const result = await requestFn();
return result;
} finally {
this.streamingActive = false;
console.log('[Ollama Queue] Streaming request completed');
}
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) {
return;
}
// Wait if streaming is active
if (this.streamingActive) {
setTimeout(() => this.process(), 100);
return;
}
this.processing = true;
while (this.queue.length > 0) {
// Check if streaming started while processing queue
if (this.streamingActive) {
this.processing = false;
setTimeout(() => this.process(), 100);
return;
}
const { requestFn, resolve, reject } = this.queue.shift();
try {
console.log(`[Ollama Queue] Processing queued request (${this.queue.length} remaining)`);
const result = await requestFn();
resolve(result);
} catch (error) {
console.error('[Ollama Queue] Request failed:', error);
reject(error);
}
}
this.processing = false;
}
}
// Global request queue instance
const requestQueue = new RequestQueue();
class OllamaProvider { class OllamaProvider {
static async validateApiKey() { static async validateApiKey() {
try { try {
@ -79,71 +152,77 @@ function createLLM({
} }
messages.push({ role: 'user', content: userContent.join('\n') }); messages.push({ role: 'user', content: userContent.join('\n') });
try { // Use request queue to prevent concurrent API calls
const response = await fetch(`${baseUrl}/api/chat`, { return await requestQueue.add(async () => {
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, const response = await fetch(`${baseUrl}/api/chat`, {
body: JSON.stringify({ method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
messages, body: JSON.stringify({
stream: false, model,
options: { messages,
temperature, stream: false,
num_predict: maxTokens, options: {
} temperature,
}) num_predict: maxTokens,
}); }
})
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`); 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) => { chat: async (messages) => {
const ollamaMessages = convertMessagesToOllamaFormat(messages); const ollamaMessages = convertMessagesToOllamaFormat(messages);
try { // Use request queue to prevent concurrent API calls
const response = await fetch(`${baseUrl}/api/chat`, { return await requestQueue.add(async () => {
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, const response = await fetch(`${baseUrl}/api/chat`, {
body: JSON.stringify({ method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
messages: ollamaMessages, body: JSON.stringify({
stream: false, model,
options: { messages: ollamaMessages,
temperature, stream: false,
num_predict: maxTokens, options: {
} temperature,
}) num_predict: maxTokens,
}); }
})
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`); 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); const ollamaMessages = convertMessagesToOllamaFormat(messages);
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages); console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
try { // Streaming requests have priority over queued requests
const response = await fetch(`${baseUrl}/api/chat`, { return await requestQueue.addStreamingRequest(async () => {
method: 'POST', try {
headers: { 'Content-Type': 'application/json' }, const response = await fetch(`${baseUrl}/api/chat`, {
body: JSON.stringify({ method: 'POST',
model, headers: { 'Content-Type': 'application/json' },
messages: ollamaMessages, body: JSON.stringify({
stream: true, model,
options: { messages: ollamaMessages,
temperature, stream: true,
num_predict: maxTokens, options: {
} temperature,
}) num_predict: maxTokens,
}); }
})
});
if (!response.ok) { if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`); throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
console.log('[Ollama Provider] Got streaming response');
const stream = new ReadableStream({
async start(controller) {
let buffer = '';
try {
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.message?.content) {
const sseData = JSON.stringify({
choices: [{
delta: {
content: data.message.content
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
}
if (data.done) {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
}
} catch (e) {
console.error('[Ollama Provider] Failed to parse chunk:', e);
}
}
});
response.body.on('end', () => {
controller.close();
console.log('[Ollama Provider] Streaming completed');
});
response.body.on('error', (error) => {
console.error('[Ollama Provider] Streaming error:', error);
controller.error(error);
});
} catch (error) {
console.error('[Ollama Provider] Streaming setup error:', error);
controller.error(error);
}
} }
});
return { console.log('[Ollama Provider] Got streaming response');
ok: true,
body: stream const stream = new ReadableStream({
}; async start(controller) {
let buffer = '';
} catch (error) {
console.error('[Ollama Provider] Request error:', error); try {
throw error; 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, silence_duration_ms: 100,
}, },
input_audio_noise_reduction: { input_audio_noise_reduction: {
type: 'far_field' type: 'near_field'
} }
} }
}; };

View File

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

View File

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

View File

@ -91,24 +91,28 @@ const LATEST_SCHEMA = {
}, },
provider_settings: { provider_settings: {
columns: [ columns: [
{ name: 'uid', type: 'TEXT NOT NULL' },
{ name: 'provider', type: 'TEXT NOT NULL' }, { name: 'provider', type: 'TEXT NOT NULL' },
{ name: 'api_key', type: 'TEXT' }, { name: 'api_key', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' }, { name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_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: 'created_at', type: 'INTEGER' },
{ name: 'updated_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: [ columns: [
{ name: 'uid', type: 'TEXT PRIMARY KEY' }, { name: 'uid', type: 'TEXT PRIMARY KEY' },
{ name: 'selected_llm_provider', type: 'TEXT' }, { name: 'keychain_completed', type: 'INTEGER DEFAULT 0' }
{ name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_provider', type: 'TEXT' },
{ name: 'selected_stt_model', type: 'TEXT' },
{ name: 'updated_at', type: 'INTEGER' }
] ]
} }
}; };

View File

@ -33,7 +33,13 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
for (const field of fieldsToEncrypt) { for (const field of fieldsToEncrypt) {
if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) { 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 = { module.exports = {
markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args), markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args),
checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...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'); const sqliteRepository = require('./sqlite.repository');
let authService = null;
function setAuthService(service) {
authService = service;
}
function getBaseRepository() { function getBaseRepository() {
if (!authService) { // For now, we only have sqlite. This could be expanded later.
throw new Error('AuthService not set for providerSettings repository'); return sqliteRepository;
}
const user = authService.getCurrentUser();
return user.isLoggedIn ? firebaseRepository : sqliteRepository;
} }
const providerSettingsRepositoryAdapter = { const providerSettingsRepositoryAdapter = {
// Core CRUD operations // Core CRUD operations
async getByProvider(provider) { async getByProvider(provider) {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId(); return await repo.getByProvider(provider);
return await repo.getByProvider(uid, provider);
}, },
async getAllByUid() { async getAll() {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId(); return await repo.getAll();
return await repo.getAllByUid(uid);
}, },
async upsert(provider, settings) { async upsert(provider, settings) {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
const now = Date.now(); const now = Date.now();
const settingsWithMeta = { const settingsWithMeta = {
...settings, ...settings,
uid,
provider, provider,
updated_at: now, updated_at: now,
created_at: settings.created_at || now created_at: settings.created_at || now
}; };
return await repo.upsert(uid, provider, settingsWithMeta); return await repo.upsert(provider, settingsWithMeta);
}, },
async remove(provider) { async remove(provider) {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId(); return await repo.remove(provider);
return await repo.remove(uid, provider);
}, },
async removeAllByUid() { async removeAll() {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId(); return await repo.removeAll();
return await repo.removeAllByUid(uid); },
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 = { module.exports = {
...providerSettingsRepositoryAdapter, ...providerSettingsRepositoryAdapter
setAuthService
}; };

View File

@ -1,37 +1,59 @@
const sqliteClient = require('../../services/sqliteClient'); const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(uid, provider) { function getByProvider(provider) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?'); const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?');
return stmt.get(uid, provider) || null; 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 db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider'); const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider');
return stmt.all(uid); 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(); const db = sqliteClient.getDb();
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE) // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at) INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(uid, provider) DO UPDATE SET ON CONFLICT(provider) DO UPDATE SET
api_key = excluded.api_key, api_key = excluded.api_key,
selected_llm_model = excluded.selected_llm_model, selected_llm_model = excluded.selected_llm_model,
selected_stt_model = excluded.selected_stt_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 updated_at = excluded.updated_at
`); `);
const result = stmt.run( const result = stmt.run(
uid,
provider, provider,
settings.api_key || null, settings.api_key || null,
settings.selected_llm_model || null, settings.selected_llm_model || null,
settings.selected_stt_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.created_at || Date.now(),
settings.updated_at settings.updated_at
); );
@ -39,24 +61,100 @@ function upsert(uid, provider, settings) {
return { changes: result.changes }; return { changes: result.changes };
} }
function remove(uid, provider) { function remove(provider) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?'); const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?');
const result = stmt.run(uid, provider); const result = stmt.run(provider);
return { changes: result.changes }; return { changes: result.changes };
} }
function removeAllByUid(uid) { function removeAll() {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?'); const stmt = db.prepare('DELETE FROM provider_settings');
const result = stmt.run(uid); const result = stmt.run();
return { changes: result.changes }; 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 = { module.exports = {
getByProvider, getByProvider,
getAllByUid, getAll,
upsert, upsert,
remove, 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; let authService = null;
function setAuthService(service) { function getAuthService() {
authService = service; if (!authService) {
authService = require('../../services/authService');
}
return authService;
} }
function getBaseRepository() { function getBaseRepository() {
if (!authService) { const service = getAuthService();
throw new Error('AuthService has not been set for the user repository.'); 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) { if (user && user.isLoggedIn) {
return firebaseRepository; return firebaseRepository;
} }
@ -25,24 +29,23 @@ const userRepositoryAdapter = {
}, },
getById: () => { getById: () => {
const uid = authService.getCurrentUserId(); const uid = getAuthService().getCurrentUserId();
return getBaseRepository().getById(uid); return getBaseRepository().getById(uid);
}, },
update: (updateData) => { update: (updateData) => {
const uid = authService.getCurrentUserId(); const uid = getAuthService().getCurrentUserId();
return getBaseRepository().update({ uid, ...updateData }); return getBaseRepository().update({ uid, ...updateData });
}, },
deleteById: () => { deleteById: () => {
const uid = authService.getCurrentUserId(); const uid = getAuthService().getCurrentUserId();
return getBaseRepository().deleteById(uid); return getBaseRepository().deleteById(uid);
} }
}; };
module.exports = { module.exports = {
...userRepositoryAdapter, ...userRepositoryAdapter
setAuthService
}; };

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 { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
const { BrowserWindow } = require('electron'); const { BrowserWindow, shell } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient'); const { getFirebaseAuth } = require('./firebaseClient');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const encryptionService = require('./encryptionService'); const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService'); const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session'); const sessionRepository = require('../repositories/session');
const providerSettingsRepository = require('../repositories/providerSettings'); const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections'); const permissionService = require('./permissionService');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) { if (!idToken) {
@ -43,23 +43,14 @@ class AuthService {
this.isInitialized = false; this.isInitialized = false;
// This ensures the key is ready before any login/logout state change. // This ensures the key is ready before any login/logout state change.
encryptionService.initializeKey(this.currentUserId);
this.initializationPromise = null; this.initializationPromise = null;
sessionRepository.setAuthService(this); sessionRepository.setAuthService(this);
providerSettingsRepository.setAuthService(this);
userModelSelectionsRepository.setAuthService(this);
} }
initialize() { initialize() {
if (this.isInitialized) return this.initializationPromise; 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) => { this.initializationPromise = new Promise((resolve) => {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => { onAuthStateChanged(auth, async (user) => {
@ -75,29 +66,32 @@ class AuthService {
// Clean up any zombie sessions from a previous run for this user. // Clean up any zombie sessions from a previous run for this user.
await sessionRepository.endAllActiveSessions(); await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the logged-in user ** // ** Initialize encryption key for the logged-in user if permissions are already granted **
await encryptionService.initializeKey(user.uid); 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 ** // ** Check for and run data migration for the user **
// No 'await' here, so it runs in the background without blocking startup. // No 'await' here, so it runs in the background without blocking startup.
migrationService.checkAndRunMigration(user); 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 if (global.modelStateService) {
(async () => { // The model state service now writes directly to the DB, no in-memory state.
try { await global.modelStateService.setFirebaseVirtualKey(virtualKey);
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);
} }
})(); 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 { } else {
// User signed OUT // User signed OUT
@ -105,7 +99,8 @@ class AuthService {
if (previousUser) { if (previousUser) {
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`); console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
if (global.modelStateService) { 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; this.currentUser = null;
@ -115,8 +110,7 @@ class AuthService {
// End active sessions for the local/default user as well. // End active sessions for the local/default user as well.
await sessionRepository.endAllActiveSessions(); await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the default/local user ** encryptionService.resetSessionKey();
await encryptionService.initializeKey(this.currentUserId);
} }
this.broadcastUserState(); this.broadcastUserState();
@ -131,6 +125,19 @@ class AuthService {
return this.initializationPromise; return this.initializationPromise;
} }
async startFirebaseAuthFlow() {
try {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
const authUrl = `${webUrl}/login?mode=electron`;
console.log(`[AuthService] Opening Firebase auth URL in browser: ${authUrl}`);
await shell.openExternal(authUrl);
return { success: true };
} catch (error) {
console.error('[AuthService] Failed to open Firebase auth URL:', error);
return { success: false, error: error.message };
}
}
async signInWithCustomToken(token) { async signInWithCustomToken(token) {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
try { try {
@ -168,7 +175,6 @@ class AuthService {
}); });
} }
getCurrentUserId() { getCurrentUserId() {
return this.currentUserId; return this.currentUserId;
} }

View File

@ -10,10 +10,13 @@ class DatabaseInitializer {
// 최종적으로 사용될 DB 경로 (쓰기 가능한 위치) // 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
const userDataPath = app.getPath('userData'); 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.dbPath = path.join(userDataPath, 'pickleglass.db');
this.dataDir = userDataPath; this.dataDir = userDataPath;
// 원본 DB 경로 (패키지 내 읽기 전용 위치) // The original DB path (read-only location in the package)
this.sourceDbPath = app.isPackaged this.sourceDbPath = app.isPackaged
? path.join(process.resourcesPath, 'data', 'pickleglass.db') ? path.join(process.resourcesPath, 'data', 'pickleglass.db')
: path.join(app.getAppPath(), 'data', 'pickleglass.db'); : path.join(app.getAppPath(), 'data', 'pickleglass.db');
@ -52,7 +55,7 @@ class DatabaseInitializer {
try { try {
this.ensureDatabaseExists(); 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. // This single call will now synchronize the schema and then init default data.
await sqliteClient.initTables(); await sqliteClient.initTables();

View File

@ -9,6 +9,8 @@ try {
keytar = null; keytar = null;
} }
const permissionService = require('./permissionService');
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
let sessionKey = null; // In-memory fallback key 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.'); throw new Error('A user ID must be provided to initialize the encryption key.');
} }
let keyRetrieved = false;
if (keytar) { if (keytar) {
try { try {
let key = await keytar.getPassword(SERVICE_NAME, userId); 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}.`); console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
} else { } else {
console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`); console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
keyRetrieved = true;
} }
sessionKey = key; sessionKey = key;
} catch (error) { } catch (error) {
@ -55,12 +60,26 @@ async function initializeKey(userId) {
sessionKey = crypto.randomBytes(32).toString('hex'); 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) { if (!sessionKey) {
throw new Error('Failed to initialize encryption key.'); throw new Error('Failed to initialize encryption key.');
} }
} }
function resetSessionKey() {
sessionKey = null;
}
/** /**
* Encrypts a given text using AES-256-GCM. * Encrypts a given text using AES-256-GCM.
* @param {string} text The text to encrypt. * @param {string} text The text to encrypt.
@ -129,12 +148,28 @@ function decrypt(encryptedText) {
} catch (error) { } catch (error) {
// It's common for this to fail if the data is not encrypted (e.g., legacy data). // 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. // In that case, we return the original value.
console.error('[EncryptionService] Decryption failed:', error);
return encryptedText; 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 = { module.exports = {
initializeKey, initializeKey,
resetSessionKey,
encrypt, encrypt,
decrypt, 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 Store = require('electron-store');
const fetch = require('node-fetch');
const { ipcMain, webContents } = require('electron');
const { PROVIDERS, getProviderClass } = require('../ai/factory'); const { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService'); const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings'); const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections'); const authService = require('./authService');
const ollamaModelRepository = require('../repositories/ollamaModel');
class ModelStateService { class ModelStateService extends EventEmitter {
constructor(authService) { constructor() {
super();
this.authService = authService; this.authService = authService;
// electron-store는 오직 레거시 데이터 마이그레이션 용도로만 사용됩니다.
this.store = new Store({ name: 'pickle-glass-model-state' }); 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() { async initialize() {
console.log('[ModelStateService] Initializing...'); console.log('[ModelStateService] Initializing one-time setup...');
await this._loadStateForCurrentUser(); await this._initializeEncryption();
this.setupIpcHandlers(); await this._runMigrations();
console.log('[ModelStateService] Initialization complete'); this.setupLocalAIStateSync();
await this._autoSelectAvailableModels([], true);
console.log('[ModelStateService] One-time setup complete.');
} }
_logCurrentSelection() { async _initializeEncryption() {
const llmModel = this.state.selectedModels.llm; try {
const sttModel = this.state.selectedModels.stt; const rows = await providerSettingsRepository.getRawApiKeys();
const llmProvider = this.getProviderForModel('llm', llmModel) || 'None'; if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) {
const sttProvider = this.getProviderForModel('stt', sttModel) || 'None'; 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() { async handleLocalAIStateChange(service, state) {
console.log('[ModelStateService] Running auto-selection for models...'); 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']; const types = ['llm', 'stt'];
types.forEach(type => { for (const type of types) {
const currentModelId = this.state.selectedModels[type]; const currentModelId = selectedModels[type];
let isCurrentModelValid = false; let isCurrentModelValid = false;
const forceReselection = forceReselectionForTypes.includes(type);
if (currentModelId) { if (currentModelId && !forceReselection) {
const provider = this.getProviderForModel(type, currentModelId); const provider = this.getProviderForModel(currentModelId, type);
const apiKey = this.getApiKey(provider); const apiKey = apiKeys[provider];
// For Ollama, 'local' is a valid API key if (provider && apiKey) {
if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) {
isCurrentModelValid = true; isCurrentModelValid = true;
} }
} }
if (!isCurrentModelValid) { if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`); console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`);
const availableModels = this.getAvailableModels(type); const availableModels = await this.getAvailableModels(type);
if (availableModels.length > 0) { if (availableModels.length > 0) {
// Prefer API providers over local providers for auto-selection
const apiModel = availableModels.find(model => { 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'; return provider && provider !== 'ollama' && provider !== 'whisper';
}); });
const newModel = apiModel || availableModels[0];
const selectedModel = apiModel || availableModels[0]; await this.setSelectedModel(type, newModel.id);
this.state.selectedModels[type] = selectedModel.id; console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`);
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${selectedModel.id} (preferred: ${apiModel ? 'API' : 'local'})`);
} else { } else {
this.state.selectedModels[type] = null; await providerSettingsRepository.setActiveProvider(null, type);
} if (!isInitialBoot) {
} this.emit('state-updated', await this.getLiveState());
});
}
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;
} }
} }
} }
// 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() { // 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다.
const userId = this.authService.getCurrentUserId(); const previousSettings = await providerSettingsRepository.getByProvider('openai-glass');
const wasPreviouslyConfigured = !!previousSettings?.api_key;
// 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();
}
async _saveState() { // 항상 새로운 가상 키로 업데이트합니다.
console.log('[ModelStateService] Saving state to database...'); await this.setApiKey('openai-glass', virtualKey);
const userId = this.authService.getCurrentUserId();
if (virtualKey) {
try { // 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다.
// Save provider settings (API keys) if (!wasPreviouslyConfigured) {
for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) { console.log('[ModelStateService] First-time setup for openai-glass, setting default models.');
if (apiKey) { const llmModel = PROVIDERS['openai-glass']?.llmModels[0];
const encryptedKey = (provider !== 'ollama' && provider !== 'whisper') const sttModel = PROVIDERS['openai-glass']?.sttModels[0];
? encryptionService.encrypt(apiKey) if (llmModel) await this.setSelectedModel('llm', llmModel.id);
: apiKey; if (sttModel) await this.setSelectedModel('stt', sttModel.id);
} else {
await providerSettingsRepository.upsert(provider, { console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.');
api_key: encryptedKey
});
} else {
// Remove empty API keys
await providerSettingsRepository.remove(provider);
}
} }
} 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 typesToReselect = [];
const llmProvider = this.state.selectedModels.llm ? this.getProviderForModel('llm', this.state.selectedModels.llm) : null; if (llmProvider === 'openai-glass') typesToReselect.push('llm');
const sttProvider = this.state.selectedModels.stt ? this.getProviderForModel('stt', this.state.selectedModels.stt) : null; if (sttProvider === 'openai-glass') typesToReselect.push('stt');
if (llmProvider || sttProvider || this.state.selectedModels.llm || this.state.selectedModels.stt) { if (typesToReselect.length > 0) {
await userModelSelectionsRepository.upsert({ console.log('[ModelStateService] Logged out, re-selecting models for:', typesToReselect.join(', '));
selected_llm_provider: llmProvider, await this._autoSelectAvailableModels(typesToReselect);
selected_llm_model: this.state.selectedModels.llm,
selected_stt_provider: sttProvider,
selected_stt_model: this.state.selectedModels.stt
});
} }
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() { async setApiKey(provider, key) {
console.log('[ModelStateService] Falling back to electron-store...'); console.log(`[ModelStateService] setApiKey for ${provider}`);
const userId = this.authService.getCurrentUserId(); if (!provider) {
const stateToSave = { throw new Error('Provider is required');
...this.state, }
apiKeys: { ...this.state.apiKeys }
// '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') { async setSelectedModel(type, modelId) {
try { const provider = this.getProviderForModel(modelId, type);
stateToSave.apiKeys[provider] = encryptionService.encrypt(key); if (!provider) {
} catch (error) { console.warn(`[ModelStateService] No provider found for model ${modelId}`);
console.error(`[ModelStateService] Failed to encrypt API key for ${provider}`); return false;
stateToSave.apiKeys[provider] = null; }
}
} 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); await providerSettingsRepository.upsert(provider, newSettings);
console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`); await providerSettingsRepository.setActiveProvider(provider, type);
this._logCurrentSelection();
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) { async validateApiKey(provider, key) {
if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) { if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
return { success: false, error: 'API key cannot be empty.' }; return { success: false, error: 'API key cannot be empty.' };
} }
const ProviderClass = getProviderClass(provider); const ProviderClass = getProviderClass(provider);
if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') { if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
// Default to success if no specific validator is found return { success: true };
console.warn(`[ModelStateService] No validateApiKey function for provider: ${provider}. Assuming valid.`);
return { success: true };
} }
try { try {
const result = await ProviderClass.validateApiKey(key); return 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;
} catch (error) { } catch (error) {
console.error(`[ModelStateService] Error during ${provider} key validation:`, error);
return { success: false, error: 'An unexpected error occurred during validation.' }; 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) { async handleRemoveApiKey(provider) {
console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`); const success = await this.removeApiKey(provider);
this.state.apiKeys['openai-glass'] = virtualKey; if (success) {
const selectedModels = await this.getSelectedModels();
const llmModels = PROVIDERS['openai-glass']?.llmModels; if (!selectedModels.llm && !selectedModels.stt) {
const sttModels = PROVIDERS['openai-glass']?.sttModels; this.emit('force-show-apikey-header');
// 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;
} }
} }
return success;
// 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;
} }
getCurrentProvider(type) { /*-------------- Compatibility Helpers --------------*/
const selectedModel = this.state.selectedModels[type]; async handleValidateKey(provider, key) {
return this.getProviderForModel(type, selectedModel); return await this.setApiKey(provider, key);
} }
isLoggedInWithFirebase() { async handleSetSelectedModel(type, modelId) {
return this.authService.getCurrentUser().isLoggedIn; return await this.setSelectedModel(type, modelId);
} }
areProvidersConfigured() { async areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true; if (this.isLoggedInWithFirebase()) return true;
const allSettings = await providerSettingsRepository.getAll();
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인 const apiKeyMap = {};
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => { allSettings.forEach(s => apiKeyMap[s.provider] = s.api_key);
if (provider === 'ollama') { // LLM
// Ollama uses dynamic models, so just check if configured (has 'local' key) const hasLlmKey = Object.entries(apiKeyMap).some(([provider, key]) => {
return key === 'local'; if (!key) return false;
} if (provider === 'whisper') return false; // whisper는 LLM 없음
if (provider === 'whisper') { return PROVIDERS[provider]?.llmModels?.length > 0;
// Whisper doesn't support LLM
return false;
}
return key && PROVIDERS[provider]?.llmModels.length > 0;
}); });
// STT
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => { const hasSttKey = Object.entries(apiKeyMap).some(([provider, key]) => {
if (provider === 'whisper') { if (!key) return false;
// Whisper has static model list and supports STT if (provider === 'ollama') return false; // ollama는 STT 없음
return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0; return PROVIDERS[provider]?.sttModels?.length > 0 || provider === 'whisper';
}
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;
}); });
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}"`; 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...'); console.log('[DB Sync] Starting schema synchronization...');
// Run special migration for provider_settings before the generic sync logic
this._migrateProviderSettings();
const tablesInDb = this.getTablesFromDb(); const tablesInDb = this.getTablesFromDb();
for (const tableName of Object.keys(LATEST_SCHEMA)) { for (const tableName of Object.keys(LATEST_SCHEMA)) {
@ -132,8 +206,8 @@ class SQLiteClient {
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`); console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
} }
initTables() { async initTables() {
this.synchronizeSchema(); await this.synchronizeSchema();
this.initDefaultData(); this.initDefaultData();
} }
@ -166,21 +240,6 @@ class SQLiteClient {
console.log('Default data initialized.'); 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() { close() {
if (this.db) { if (this.db) {
try { 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 path = require('path');
const fs = require('fs'); const fs = require('fs');
const os = require('os'); const os = require('os');
const LocalAIServiceBase = require('./localAIServiceBase'); const https = require('https');
const crypto = require('crypto');
const { spawnAsync } = require('../utils/spawnHelper'); const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums'); const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
const execAsync = promisify(exec);
const fsPromises = fs.promises; const fsPromises = fs.promises;
class WhisperService extends LocalAIServiceBase { class WhisperService extends EventEmitter {
constructor() { constructor() {
super('WhisperService'); super();
this.isInitialized = false; this.serviceName = 'WhisperService';
// 경로 및 디렉토리
this.whisperPath = null; this.whisperPath = null;
this.modelsDir = null; this.modelsDir = null;
this.tempDir = null; this.tempDir = null;
// 세션 관리 (세션 풀 내장)
this.sessionPool = [];
this.activeSessions = new Map();
this.maxSessions = 3;
// 설치 상태
this.installState = {
isInstalled: false,
isInitialized: false
};
// 사용 가능한 모델
this.availableModels = { this.availableModels = {
'whisper-tiny': { 'whisper-tiny': {
name: '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() { async initialize() {
if (this.isInitialized) return; if (this.installState.isInitialized) return;
try { try {
const homeDir = os.homedir(); const homeDir = os.homedir();
@ -51,16 +285,21 @@ class WhisperService extends LocalAIServiceBase {
// Windows에서는 .exe 확장자 필요 // Windows에서는 .exe 확장자 필요
const platform = this.getPlatform(); 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); this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable);
await this.ensureDirectories(); await this.ensureDirectories();
await this.ensureWhisperBinary(); await this.ensureWhisperBinary();
this.isInitialized = true; this.installState.isInitialized = true;
console.log('[WhisperService] Initialized successfully'); console.log('[WhisperService] Initialized successfully');
} catch (error) { } catch (error) {
console.error('[WhisperService] Initialization failed:', error); console.error('[WhisperService] Initialization failed:', error);
// Emit error event - LocalAIManager가 처리
this.emit('error', {
errorType: 'initialization-failed',
error: error.message
});
throw error; throw error;
} }
} }
@ -71,6 +310,56 @@ class WhisperService extends LocalAIServiceBase {
await fsPromises.mkdir(path.dirname(this.whisperPath), { recursive: true }); 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() { async ensureWhisperBinary() {
const whisperCliPath = await this.checkCommand('whisper-cli'); const whisperCliPath = await this.checkCommand('whisper-cli');
if (whisperCliPath) { if (whisperCliPath) {
@ -99,6 +388,11 @@ class WhisperService extends LocalAIServiceBase {
console.log('[WhisperService] Whisper not found, trying Homebrew installation...'); console.log('[WhisperService] Whisper not found, trying Homebrew installation...');
try { try {
await this.installViaHomebrew(); await this.installViaHomebrew();
// verify installation
const verified = await this.verifyInstallation();
if (!verified.success) {
throw new Error(verified.error);
}
return; return;
} catch (error) { } catch (error) {
console.log('[WhisperService] Homebrew installation failed:', error.message); console.log('[WhisperService] Homebrew installation failed:', error.message);
@ -106,6 +400,12 @@ class WhisperService extends LocalAIServiceBase {
} }
await this.autoInstall(); await this.autoInstall();
// verify installation
const verified = await this.verifyInstallation();
if (!verified.success) {
throw new Error(`Whisper installation verification failed: ${verified.error}`);
}
} }
async installViaHomebrew() { async installViaHomebrew() {
@ -132,7 +432,7 @@ class WhisperService extends LocalAIServiceBase {
async ensureModelAvailable(modelId) { async ensureModelAvailable(modelId) {
if (!this.isInitialized) { if (!this.installState.isInitialized) {
console.log('[WhisperService] Service not initialized, initializing now...'); console.log('[WhisperService] Service not initialized, initializing now...');
await this.initialize(); await this.initialize();
} }
@ -157,21 +457,60 @@ class WhisperService extends LocalAIServiceBase {
const modelPath = await this.getModelPath(modelId); const modelPath = await this.getModelPath(modelId);
const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId]; const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
this.emit('downloadProgress', { modelId, progress: 0 }); // Emit progress event - LocalAIManager가 처리
this.emit('install-progress', {
model: modelId,
progress: 0
});
await this.downloadWithRetry(modelInfo.url, modelPath, { await this.downloadWithRetry(modelInfo.url, modelPath, {
expectedChecksum: checksumInfo?.sha256, expectedChecksum: checksumInfo?.sha256,
modelId, // pass modelId to LocalAIServiceBase for event handling
onProgress: (progress) => { 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`); 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) { async getModelPath(modelId) {
if (!this.isInitialized || !this.modelsDir) { if (!this.installState.isInitialized || !this.modelsDir) {
throw new Error('WhisperService is not initialized. Call initialize() first.'); throw new Error('WhisperService is not initialized. Call initialize() first.');
} }
return path.join(this.modelsDir, `${modelId}.bin`); return path.join(this.modelsDir, `${modelId}.bin`);
@ -196,7 +535,7 @@ class WhisperService extends LocalAIServiceBase {
createWavHeader(dataSize) { createWavHeader(dataSize) {
const header = Buffer.alloc(44); const header = Buffer.alloc(44);
const sampleRate = 24000; const sampleRate = 16000;
const numChannels = 1; const numChannels = 1;
const bitsPerSample = 16; const bitsPerSample = 16;
@ -245,7 +584,7 @@ class WhisperService extends LocalAIServiceBase {
} }
async getInstalledModels() { async getInstalledModels() {
if (!this.isInitialized) { if (!this.installState.isInitialized) {
console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...'); console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...');
await this.initialize(); await this.initialize();
} }
@ -274,11 +613,11 @@ class WhisperService extends LocalAIServiceBase {
} }
async isServiceRunning() { async isServiceRunning() {
return this.isInitialized; return this.installState.isInitialized;
} }
async startService() { async startService() {
if (!this.isInitialized) { if (!this.installState.isInitialized) {
await this.initialize(); await this.initialize();
} }
return true; return true;
@ -304,7 +643,7 @@ class WhisperService extends LocalAIServiceBase {
async installWindows() { async installWindows() {
console.log('[WhisperService] Installing Whisper on Windows...'); console.log('[WhisperService] Installing Whisper on Windows...');
const version = 'v1.7.6'; 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'); const tempFile = path.join(this.tempDir, 'whisper-binary.zip');
try { try {
@ -382,8 +721,7 @@ class WhisperService extends LocalAIServiceBase {
if (item.isDirectory()) { if (item.isDirectory()) {
const subExecutables = await this.findWhisperExecutables(fullPath); const subExecutables = await this.findWhisperExecutables(fullPath);
executables.push(...subExecutables); executables.push(...subExecutables);
} else if (item.isFile() && (item.name === 'whisper.exe' || item.name === 'main.exe')) { } else if (item.isFile() && (item.name === 'whisper-whisper.exe' || item.name === 'whisper.exe' || item.name === 'main.exe')) {
// main.exe도 포함 (일부 빌드에서 whisper 실행파일이 main.exe로 명명됨)
executables.push(fullPath); executables.push(fullPath);
} }
} }
@ -418,7 +756,7 @@ class WhisperService extends LocalAIServiceBase {
async installLinux() { async installLinux() {
console.log('[WhisperService] Installing Whisper on Linux...'); console.log('[WhisperService] Installing Whisper on Linux...');
const version = 'v1.7.6'; 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'); const tempFile = path.join(this.tempDir, 'whisper-binary.tar.gz');
try { 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 SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService'); const SummaryService = require('./summary/summaryService');
const authService = require('../common/services/authService'); const authService = require('../common/services/authService');
const sessionRepository = require('../common/repositories/session'); const sessionRepository = require('../common/repositories/session');
const sttRepository = require('./stt/repositories'); const sttRepository = require('./stt/repositories');
const internalBridge = require('../../bridge/internalBridge');
class ListenService { class ListenService {
constructor() { constructor() {
@ -11,8 +12,9 @@ class ListenService {
this.summaryService = new SummaryService(); this.summaryService = new SummaryService();
this.currentSessionId = null; this.currentSessionId = null;
this.isInitializingSession = false; this.isInitializingSession = false;
this.setupServiceCallbacks(); this.setupServiceCallbacks();
console.log('[ListenService] Service instance created.');
} }
setupServiceCallbacks() { setupServiceCallbacks() {
@ -38,11 +40,60 @@ class ListenService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { const { windowPool } = require('../../window/windowManager');
if (!win.isDestroyed()) { const listenWindow = windowPool?.get('listen');
win.webContents.send(channel, data);
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
initialize() {
this.setupIpcHandlers();
console.log('[ListenService] Initialized and ready.');
}
async handleListenRequest(listenButtonText) {
const { windowPool } = 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) { async handleTranscriptionComplete(speaker, text) {
@ -158,8 +209,8 @@ class ListenService {
} }
} }
async sendAudioContent(data, mimeType) { async sendMicAudioContent(data, mimeType) {
return await this.sttService.sendAudioContent(data, mimeType); return await this.sttService.sendMicAudioContent(data, mimeType);
} }
async startMacOSAudioCapture() { async startMacOSAudioCapture() {
@ -183,6 +234,8 @@ class ListenService {
// Close STT sessions // Close STT sessions
await this.sttService.closeSessions(); await this.sttService.closeSessions();
await this.stopMacOSAudioCapture();
// End database session // End database session
if (this.currentSessionId) { if (this.currentSessionId) {
await sessionRepository.end(this.currentSessionId); await sessionRepository.end(this.currentSessionId);
@ -214,88 +267,58 @@ class ListenService {
return this.summaryService.getConversationHistory(); return this.summaryService.getConversationHistory();
} }
setupIpcHandlers() { _createHandler(asyncFn, successMessage, errorMessage) {
const { ipcMain } = require('electron'); return async (...args) => {
ipcMain.handle('is-session-active', async () => {
const isActive = this.isSessionActive();
console.log(`Checking session status. Active: ${isActive}`);
return isActive;
});
ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
const success = await this.initializeSession(language);
return success;
});
ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
try { try {
await this.sendAudioContent(data, mimeType); const result = await asyncFn.apply(this, args);
return { success: true }; if (successMessage) console.log(successMessage);
// `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로,
// 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다.
// 다른 함수들은 이미 success 객체를 반환합니다.
return result && typeof result.success !== 'undefined' ? result : { success: true };
} catch (e) { } catch (e) {
console.error('Error sending user audio:', e); console.error(errorMessage, e);
return { success: false, error: e.message }; return { success: false, error: e.message };
} }
}); };
}
ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => { // `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다.
try { handleSendMicAudioContent = this._createHandler(
await this.sttService.sendSystemAudioContent(data, mimeType); this.sendMicAudioContent,
null,
// Send system audio data back to renderer for AEC reference (like macOS does) 'Error sending user audio:'
this.sendToRenderer('system-audio-data', { data }); );
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('start-macos-audio', async () => { handleStartMacosAudio = this._createHandler(
async () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
return { success: false, error: 'macOS audio capture only available on macOS' }; return { success: false, error: 'macOS audio capture only available on macOS' };
} }
if (this.sttService.isMacOSAudioRunning?.()) { if (this.sttService.isMacOSAudioRunning?.()) {
return { success: false, error: 'already_running' }; return { success: false, error: 'already_running' };
} }
await this.startMacOSAudioCapture();
return { success: true, error: null };
},
'macOS audio capture started.',
'Error starting macOS audio capture:'
);
handleStopMacosAudio = this._createHandler(
this.stopMacOSAudioCapture,
'macOS audio capture stopped.',
'Error stopping macOS audio capture:'
);
try { handleUpdateGoogleSearchSetting = this._createHandler(
const success = await this.startMacOSAudioCapture(); async (enabled) => {
return { success, error: null }; console.log('Google Search setting updated to:', enabled);
} catch (error) { },
console.error('Error starting macOS audio capture:', error); null,
return { success: false, error: error.message }; 'Error updating Google Search setting:'
} );
});
ipcMain.handle('stop-macos-audio', async () => {
try {
this.stopMacOSAudioCapture();
return { success: true };
} catch (error) {
console.error('Error stopping macOS audio capture:', error);
return { success: false, error: error.message };
}
});
// ipcMain.handle('close-session', async () => {
// return await this.closeSession();
// });
ipcMain.handle('update-google-search-setting', async (event, enabled) => {
try {
console.log('Google Search setting updated to:', enabled);
return { success: true };
} catch (error) {
console.error('Error updating Google Search setting:', error);
return { success: false, error: error.message };
}
});
console.log('✅ Listen service IPC handlers registered');
}
} }
module.exports = ListenService; const listenService = new ListenService();
module.exports = listenService;

View File

@ -1,7 +1,7 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createSTT } = require('../../common/ai/factory'); const { createSTT } = require('../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager'); const modelStateService = require('../../common/services/modelStateService');
const COMPLETION_DEBOUNCE_MS = 2000; const COMPLETION_DEBOUNCE_MS = 2000;
@ -34,11 +34,24 @@ class SttService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { // Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
if (!win.isDestroyed()) { const { windowPool } = require('../../../window/windowManager');
win.webContents.send(channel, data); const listenWindow = windowPool?.get('listen');
}
}); if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
async handleSendSystemAudioContent(data, mimeType) {
try {
await this.sendSystemAudioContent(data, mimeType);
this.sendToRenderer('system-audio-data', { data });
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
} }
flushMyCompletion() { flushMyCompletion() {
@ -120,7 +133,7 @@ class SttService {
async initializeSttSessions(language = 'en') { async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
const modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); const modelInfo = await modelStateService.getCurrentModelInfo('stt');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
@ -132,6 +145,7 @@ class SttService {
console.log('[SttService] Ignoring message - session already closed'); console.log('[SttService] Ignoring message - session already closed');
return; return;
} }
// console.log('[SttService] handleMyMessage', message);
if (this.modelInfo.provider === 'whisper') { if (this.modelInfo.provider === 'whisper') {
// Whisper STT emits 'transcription' events with different structure // Whisper STT emits 'transcription' events with different structure
@ -152,10 +166,6 @@ class SttService {
'(NOISE)' '(NOISE)'
]; ];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern => const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern finalText.includes(pattern) || finalText === pattern
); );
@ -206,6 +216,38 @@ class SttService {
isFinal: false, isFinal: false,
timestamp: Date.now(), 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 { } else {
const type = message.type; const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -265,9 +307,6 @@ class SttService {
'(NOISE)' '(NOISE)'
]; ];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern => const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern finalText.includes(pattern) || finalText === pattern
); );
@ -319,6 +358,34 @@ class SttService {
isFinal: false, isFinal: false,
timestamp: Date.now(), 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 { } else {
const type = message.type; const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -388,7 +455,7 @@ class SttService {
return true; return true;
} }
async sendAudioContent(data, mimeType) { async sendMicAudioContent(data, mimeType) {
// const provider = await this.getAiProvider(); // const provider = await this.getAiProvider();
// const isGemini = provider === 'gemini'; // const isGemini = provider === 'gemini';
@ -399,16 +466,20 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = await modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
} }
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: data; 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); await this.mySttSession.sendRealtimeInput(payload);
} }
@ -420,16 +491,21 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = await modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
} }
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: data; 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); await this.theirSttSession.sendRealtimeInput(payload);
} }
@ -501,7 +577,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = await modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
@ -521,9 +597,15 @@ class SttService {
if (this.theirSttSession) { if (this.theirSttSession) {
try { try {
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: base64Data; 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); await this.theirSttSession.sendRealtimeInput(payload);
} catch (err) { } catch (err) {
console.error('Error sending system audio:', err.message); 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 { createLLM } = require('../../common/ai/factory');
const sessionRepository = require('../../common/repositories/session'); const sessionRepository = require('../../common/repositories/session');
const summaryRepository = require('./repositories'); const summaryRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js'); const modelStateService = require('../../common/services/modelStateService');
class SummaryService { class SummaryService {
constructor() { constructor() {
@ -27,11 +27,12 @@ class SummaryService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { const { windowPool } = require('../../../window/windowManager');
if (!win.isDestroyed()) { const listenWindow = windowPool?.get('listen');
win.webContents.send(channel, data);
} if (listenWindow && !listenWindow.isDestroyed()) {
}); listenWindow.webContents.send(channel, data);
}
} }
addConversationTurn(speaker, text) { addConversationTurn(speaker, text) {
@ -97,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId); await sessionRepository.touch(this.currentSessionId);
} }
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); const modelInfo = await modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
@ -303,25 +304,20 @@ Keep all points concise and build upon previous analysis if provided.`,
*/ */
async triggerAnalysisIfNeeded() { async triggerAnalysisIfNeeded() {
if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) { if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`); console.log(`Triggering analysis - ${this.conversationHistory.length} conversation texts accumulated`);
this.makeOutlineAndRequests(this.conversationHistory) const data = await this.makeOutlineAndRequests(this.conversationHistory);
.then(data => { if (data) {
if (data) { console.log('Sending structured data to renderer');
console.log('📤 Sending structured data to renderer'); this.sendToRenderer('summary-update', data);
this.sendToRenderer('summary-update', data);
// Notify callback
// Notify callback if (this.onAnalysisComplete) {
if (this.onAnalysisComplete) { this.onAnalysisComplete(data);
this.onAnalysisComplete(data); }
} } else {
} else { console.log('No analysis data returned');
console.log('❌ No analysis data returned from non-blocking call'); }
}
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
} }
} }

View File

@ -4,6 +4,10 @@ const authService = require('../common/services/authService');
const settingsRepository = require('./repositories'); const settingsRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager'); 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({ const store = new Store({
name: 'pickle-glass-settings', name: 'pickle-glass-settings',
defaults: { defaults: {
@ -19,6 +23,52 @@ const NOTIFICATION_CONFIG = {
RETRY_BASE_DELAY: 1000, // exponential backoff base (ms) RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
}; };
// New facade functions for model state management
async function getModelSettings() {
try {
const [config, storedKeys, selectedModels, 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 // window targeting system
class WindowNotificationManager { class WindowNotificationManager {
constructor() { constructor() {
@ -324,6 +374,7 @@ async function removeApiKey() {
} }
}); });
console.log('[SettingsService] API key removed for all providers');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[SettingsService] Error removing API key:', error); console.error('[SettingsService] Error removing API key:', error);
@ -373,8 +424,6 @@ function initialize() {
// cleanup // cleanup
windowNotificationManager.cleanup(); windowNotificationManager.cleanup();
// IPC handlers 제거 (featureBridge로 이동)
console.log('[SettingsService] Initialized and ready.'); console.log('[SettingsService] Initialized and ready.');
} }
@ -406,4 +455,13 @@ module.exports = {
removeApiKey, removeApiKey,
updateContentProtection, updateContentProtection,
getAutoUpdateSetting, 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 { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
const { createWindows } = require('./window/windowManager.js'); 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 { initializeFirebase } = require('./features/common/services/firebaseClient');
const databaseInitializer = require('./features/common/services/databaseInitializer'); const databaseInitializer = require('./features/common/services/databaseInitializer');
const authService = require('./features/common/services/authService'); const authService = require('./features/common/services/authService');
@ -25,21 +25,16 @@ const { EventEmitter } = require('events');
const askService = require('./features/ask/askService'); const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService'); const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./features/common/repositories/session'); const sessionRepository = require('./features/common/repositories/session');
const ModelStateService = require('./features/common/services/modelStateService'); const modelStateService = require('./features/common/services/modelStateService');
const sqliteClient = require('./features/common/services/sqliteClient');
const featureBridge = require('./bridge/featureBridge'); const featureBridge = require('./bridge/featureBridge');
const windowBridge = require('./bridge/windowBridge');
// Global variables // Global variables
const eventBridge = new EventEmitter(); const eventBridge = new EventEmitter();
let WEB_PORT = 3000; let WEB_PORT = 3000;
let isShuttingDown = false; // Flag to prevent infinite shutdown loop let isShuttingDown = false; // Flag to prevent infinite shutdown loop
const listenService = new ListenService();
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
global.listenService = listenService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService; global.modelStateService = modelStateService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
@ -203,13 +198,9 @@ app.whenReady().then(async () => {
await modelStateService.initialize(); await modelStateService.initialize();
//////// after_modelStateService //////// //////// after_modelStateService ////////
listenService.setupIpcHandlers();
askService.initialize();
settingsService.initialize();
featureBridge.initialize(); // 추가: featureBridge 초기화 featureBridge.initialize(); // 추가: featureBridge 초기화
setupGeneralIpcHandlers(); windowBridge.initialize();
setupOllamaIpcHandlers(); setupWebDataHandlers();
setupWhisperIpcHandlers();
// Initialize Ollama models in database // Initialize Ollama models in database
await ollamaModelRepository.initializeDefaultModels(); 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) => { app.on('before-quit', async (event) => {
// Prevent infinite loop by checking if shutdown is already in progress // Prevent infinite loop by checking if shutdown is already in progress
if (isShuttingDown) { if (isShuttingDown) {
@ -274,7 +258,7 @@ app.on('before-quit', async (event) => {
try { try {
// 1. Stop audio capture first (immediate) // 1. Stop audio capture first (immediate)
listenService.stopMacOSAudioCapture(); await listenService.closeSession();
console.log('[Shutdown] Audio capture stopped'); console.log('[Shutdown] Audio capture stopped');
// 2. End all active sessions (database operations) - with error handling // 2. End all active sessions (database operations) - with error handling
@ -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() { function setupWebDataHandlers() {
const sessionRepository = require('./features/common/repositories/session'); const sessionRepository = require('./features/common/repositories/session');
const sttRepository = require('./features/listen/stt/repositories'); 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(`✅ API server started on http://localhost:${apiPort}`);
console.log(`🚀 All services ready:`); console.log(`🚀 All services ready:
console.log(` Frontend: http://localhost:${frontendPort}`); Frontend: http://localhost:${frontendPort}
console.log(` API: http://localhost:${apiPort}`); API: http://localhost:${apiPort}`);
return frontendPort; return frontendPort;
} }
// Auto-update initialization // Auto-update initialization
async function initAutoUpdater() { async function initAutoUpdater() {
if (process.env.NODE_ENV === 'development') {
console.log('Development environment, skipping auto-updater.');
return;
}
try { try {
const autoUpdateEnabled = await settingsService.getAutoUpdateSetting(); await autoUpdater.checkForUpdates();
if (!autoUpdateEnabled) { autoUpdater.on('update-available', () => {
console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings'); console.log('Update available!');
return; autoUpdater.downloadUpdate();
}
// 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',
}); });
autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => {
// Immediately check for updates & notify console.log('Update downloaded:', releaseNotes, releaseName, date, url);
autoUpdater.checkForUpdatesAndNotify() dialog.showMessageBox({
.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 = {
type: 'info', type: 'info',
buttons: ['Install now', 'Install on next launch'], title: 'Application Update',
title: 'Update Available', message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`,
message: 'A new version of Glass is ready to be installed.', buttons: ['Restart', 'Later']
defaultId: 0, }).then(response => {
cancelId: 1 if (response.response === 0) {
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
// returnValue.response 0 is for 'Install Now'
if (returnValue.response === 0) {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} }
}); });
}); });
} catch (e) { autoUpdater.on('error', (err) => {
console.error('[AutoUpdater] Failed to initialise:', e); 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'); const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', { contextBridge.exposeInMainWorld('api', {
// Ask // Platform information for renderer processes
ask: { platform: {
// sendMessage isLinux: process.platform === 'linux',
sendMessage: (message) => ipcRenderer.invoke('ask:sendMessage', message), isMacOS: process.platform === 'darwin',
isWindows: process.platform === 'win32',
// window platform: process.platform
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)
}, },
// Listen // Common utilities used across multiple components
listen: { common: {
// window // User & Auth
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height), getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
// 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
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'), startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
getCurrentUser: () => ipcRenderer.invoke('get-current-user'), firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
quitApplication: () => ipcRenderer.invoke('quit-application'),
// event listener // App Control
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'),
quitApplication: () => ipcRenderer.invoke('quit-application'), quitApplication: () => ipcRenderer.invoke('quit-application'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'), openExternal: (url) => ipcRenderer.invoke('open-external', url),
ollamaShutdown: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
// on methods (listeners) // User state listener (used by multiple components)
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback), onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('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 // UI Component specific namespaces
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'), // src/ui/app/ApiKeyHeader.js
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window') 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 // src/ui/app/HeaderController.js
showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds), headerController: {
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'), // 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'), cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction), showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
openLoginPage: () => ipcRenderer.invoke('open-login-page'), 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'), firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
ollamaShutdown: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'), 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), onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback), removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback), onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback),
@ -322,11 +255,52 @@ contextBridge.exposeInMainWorld('api', {
removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback), removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback), onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback), removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), // 통합 LocalAI 이벤트 사용
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), 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 // src/ui/settings/ShortCutSettingsView.js
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'), shortcutSettingsView: {
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window') // 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 './MainHeader.js';
import './ApiKeyHeader.js'; import './ApiKeyHeader.js';
import './PermissionHeader.js'; import './PermissionHeader.js';
import './WelcomeHeader.js';
class HeaderTransitionManager { class HeaderTransitionManager {
constructor() { constructor() {
this.headerContainer = document.getElementById('header-container'); this.headerContainer = document.getElementById('header-container');
this.currentHeaderType = null; // 'apikey' | 'main' | 'permission' this.currentHeaderType = null; // 'welcome' | 'apikey' | 'main' | 'permission'
this.welcomeHeader = null;
this.apiKeyHeader = null; this.apiKeyHeader = null;
this.mainHeader = null; this.mainHeader = null;
this.permissionHeader = null; this.permissionHeader = null;
/** /**
* only one header window is allowed * only one header window is allowed
* @param {'apikey'|'main'|'permission'} type * @param {'welcome'|'apikey'|'main'|'permission'} type
*/ */
this.ensureHeader = (type) => { this.ensureHeader = (type) => {
console.log('[HeaderController] ensureHeader: Ensuring header of type:', type); console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
@ -23,18 +25,39 @@ class HeaderTransitionManager {
this.headerContainer.innerHTML = ''; this.headerContainer.innerHTML = '';
this.welcomeHeader = null;
this.apiKeyHeader = null; this.apiKeyHeader = null;
this.mainHeader = null; this.mainHeader = null;
this.permissionHeader = null; this.permissionHeader = null;
// Create new header element // Create new header element
if (type === 'apikey') { if (type === 'welcome') {
this.welcomeHeader = document.createElement('welcome-header');
this.welcomeHeader.loginCallback = () => this.handleLoginOption();
this.welcomeHeader.apiKeyCallback = () => this.handleApiKeyOption();
this.headerContainer.appendChild(this.welcomeHeader);
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
} else if (type === 'apikey') {
this.apiKeyHeader = document.createElement('apikey-header'); this.apiKeyHeader = document.createElement('apikey-header');
this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState); this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
this.apiKeyHeader.addEventListener('request-resize', e => {
this._resizeForApiKey(e.detail.height);
});
this.headerContainer.appendChild(this.apiKeyHeader); this.headerContainer.appendChild(this.apiKeyHeader);
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
} else if (type === 'permission') { } else if (type === 'permission') {
this.permissionHeader = document.createElement('permission-setup'); this.permissionHeader = document.createElement('permission-setup');
this.permissionHeader.continueCallback = () => this.transitionToMainHeader(); this.permissionHeader.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); this.headerContainer.appendChild(this.permissionHeader);
} else { } else {
this.mainHeader = document.createElement('main-header'); this.mainHeader = document.createElement('main-header');
@ -48,72 +71,105 @@ class HeaderTransitionManager {
console.log('[HeaderController] Manager initialized'); console.log('[HeaderController] Manager initialized');
// WelcomeHeader 콜백 메서드들
this.handleLoginOption = this.handleLoginOption.bind(this);
this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
this._bootstrap(); this._bootstrap();
if (window.api && window.api.controller) { if (window.api) {
window.api.controller.onUserStateChanged((event, userState) => { window.api.headerController.onUserStateChanged((event, userState) => {
console.log('[HeaderController] Received user state change:', userState); console.log('[HeaderController] Received user state change:', userState);
this.handleStateUpdate(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); console.error('[HeaderController] Received auth failure from main process:', message);
if (this.apiKeyHeader) { if (this.apiKeyHeader) {
this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.'; this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.';
this.apiKeyHeader.isLoading = false; this.apiKeyHeader.isLoading = false;
} }
}); });
window.api.controller.onForceShowApiKeyHeader(async () => { window.api.headerController.onForceShowApiKeyHeader(async () => {
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.'); console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
await this._resizeForApiKey(); const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
this.ensureHeader('apikey'); if (!isConfigured) {
}); await this._resizeForWelcome();
this.ensureHeader('welcome');
} else {
await this._resizeForApiKey();
this.ensureHeader('apikey');
}
});
} }
} }
notifyHeaderState(stateOverride) { notifyHeaderState(stateOverride) {
const state = stateOverride || this.currentHeaderType || 'apikey'; const state = stateOverride || this.currentHeaderType || 'apikey';
if (window.api && window.api.controller) { if (window.api) {
window.api.controller.sendHeaderStateChanged(state); window.api.headerController.sendHeaderStateChanged(state);
} }
} }
async _bootstrap() { async _bootstrap() {
// The initial state will be sent by the main process via 'user-state-changed' // The initial state will be sent by the main process via 'user-state-changed'
// We just need to request it. // We just need to request it.
if (window.api && window.api.controller) { if (window.api) {
const userState = await window.api.controller.getCurrentUser(); const userState = await window.api.common.getCurrentUser();
console.log('[HeaderController] Bootstrapping with initial user state:', userState); console.log('[HeaderController] Bootstrapping with initial user state:', userState);
this.handleStateUpdate(userState); this.handleStateUpdate(userState);
} else { } else {
// Fallback for non-electron environment (testing/web) // Fallback for non-electron environment (testing/web)
this.ensureHeader('apikey'); this.ensureHeader('welcome');
} }
} }
//////// after_modelStateService //////// //////// after_modelStateService ////////
async handleStateUpdate(userState) { async handleStateUpdate(userState) {
if (!window.api || !window.api.controller) return; const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
const isConfigured = await window.api.controller.areProvidersConfigured();
if (isConfigured) { if (isConfigured) {
const { isLoggedIn } = userState; // If providers are configured, always check permissions regardless of login state.
if (isLoggedIn) { const permissionResult = await this.checkPermissions();
const permissionResult = await this.checkPermissions(); if (permissionResult.success) {
if (permissionResult.success) {
this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
}
} else {
this.transitionToMainHeader(); this.transitionToMainHeader();
} else {
this.transitionToPermissionHeader();
} }
} else { } else {
await this._resizeForApiKey(); // If no providers are configured, show the welcome header to prompt for setup.
this.ensureHeader('apikey'); await this._resizeForWelcome();
this.ensureHeader('welcome');
} }
} }
// WelcomeHeader 콜백 메서드들
async handleLoginOption() {
console.log('[HeaderController] Login option selected');
if (window.api) {
await window.api.common.startFirebaseAuth();
}
}
async handleApiKeyOption() {
console.log('[HeaderController] API key option selected');
await this._resizeForApiKey(400);
this.ensureHeader('apikey');
// ApiKeyHeader에 뒤로가기 콜백 설정
if (this.apiKeyHeader) {
this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
}
}
async transitionToWelcomeHeader() {
if (this.currentHeaderType === 'welcome') {
return this._resizeForWelcome();
}
await this._resizeForWelcome();
this.ensureHeader('welcome');
}
//////// after_modelStateService //////// //////// after_modelStateService ////////
async transitionToPermissionHeader() { async transitionToPermissionHeader() {
@ -124,9 +180,9 @@ class HeaderTransitionManager {
} }
// Check if permissions were previously completed // Check if permissions were previously completed
if (window.api && window.api.controller) { if (window.api) {
try { try {
const permissionsCompleted = await window.api.controller.checkPermissionsCompleted(); const permissionsCompleted = await window.api.headerController.checkPermissionsCompleted();
if (permissionsCompleted) { if (permissionsCompleted) {
console.log('[HeaderController] Permissions were previously completed, checking current status...'); console.log('[HeaderController] Permissions were previously completed, checking current status...');
@ -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'); this.ensureHeader('permission');
} }
@ -158,31 +226,39 @@ class HeaderTransitionManager {
this.ensureHeader('main'); this.ensureHeader('main');
} }
_resizeForMain() { async _resizeForMain() {
if (!window.api || !window.api.controller) return; if (!window.api) return;
return window.api.controller.resizeHeaderWindow({ width: 353, height: 47 }) 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(() => {}); .catch(() => {});
} }
async _resizeForApiKey() { async _resizeForWelcome() {
if (!window.api || !window.api.controller) return; if (!window.api) return;
return window.api.controller.resizeHeaderWindow({ width: 350, height: 300 }) console.log('[HeaderController] _resizeForWelcome: Resizing window to 456x370');
.catch(() => {}); return window.api.headerController.resizeHeaderWindow({ width: 456, height: 364 })
}
async _resizeForPermissionHeader() {
if (!window.api || !window.api.controller) return;
return window.api.controller.resizeHeaderWindow({ width: 285, height: 220 })
.catch(() => {}); .catch(() => {});
} }
async checkPermissions() { async checkPermissions() {
if (!window.api || !window.api.controller) { if (!window.api) {
return { success: true }; return { success: true };
} }
try { try {
const permissions = await window.api.controller.checkSystemPermissions(); const permissions = await window.api.headerController.checkSystemPermissions();
console.log('[HeaderController] Current permissions:', permissions); console.log('[HeaderController] Current permissions:', permissions);
if (!permissions.needsSetup) { if (!permissions.needsSetup) {

View File

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

View File

@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement {
.container { .container {
-webkit-app-region: drag; -webkit-app-region: drag;
width: 285px; width: 285px;
height: 220px; /* height is now set dynamically */
padding: 18px 20px; padding: 18px 20px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
border-radius: 16px; border-radius: 16px;
@ -103,6 +103,12 @@ export class PermissionHeader extends LitElement {
margin-top: auto; margin-top: auto;
} }
.form-content.all-granted {
flex-grow: 1;
justify-content: center;
margin-top: 0;
}
.subtitle { .subtitle {
color: rgba(255, 255, 255, 0.7); color: rgba(255, 255, 255, 0.7);
font-size: 11px; font-size: 11px;
@ -258,24 +264,60 @@ export class PermissionHeader extends LitElement {
static properties = { static properties = {
microphoneGranted: { type: String }, microphoneGranted: { type: String },
screenGranted: { type: String }, screenGranted: { type: String },
keychainGranted: { type: String },
isChecking: { type: String }, isChecking: { type: String },
continueCallback: { type: Function } continueCallback: { type: Function },
userMode: { type: String }, // 'local' or 'firebase'
}; };
constructor() { constructor() {
super(); super();
this.microphoneGranted = 'unknown'; this.microphoneGranted = 'unknown';
this.screenGranted = 'unknown'; this.screenGranted = 'unknown';
this.keychainGranted = 'unknown';
this.isChecking = false; this.isChecking = false;
this.continueCallback = null; 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() { async connectedCallback() {
super.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(); await this.checkPermissions();
// Set up periodic permission check // 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(); this.checkPermissions();
}, 1000); }, 1000);
} }
@ -288,29 +330,35 @@ export class PermissionHeader extends LitElement {
} }
async checkPermissions() { async checkPermissions() {
if (!window.api || !window.api.permissions || this.isChecking) return; if (!window.api || this.isChecking) return;
this.isChecking = true; this.isChecking = true;
try { try {
const permissions = await window.api.permissions.checkSystemPermissions(); const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Permission check result:', permissions); console.log('[PermissionHeader] Permission check result:', permissions);
const prevMic = this.microphoneGranted; const prevMic = this.microphoneGranted;
const prevScreen = this.screenGranted; const prevScreen = this.screenGranted;
const prevKeychain = this.keychainGranted;
this.microphoneGranted = permissions.microphone; this.microphoneGranted = permissions.microphone;
this.screenGranted = permissions.screen; this.screenGranted = permissions.screen;
this.keychainGranted = permissions.keychain;
// if permissions changed == UI update // 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'); console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate(); this.requestUpdate();
} }
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
// if all permissions granted == automatically continue // if all permissions granted == automatically continue
if (this.microphoneGranted === 'granted' && if (this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' && this.screenGranted === 'granted' &&
keychainOk &&
this.continueCallback) { this.continueCallback) {
console.log('[PermissionHeader] All permissions granted, proceeding automatically'); console.log('[PermissionHeader] All permissions granted, proceeding automatically');
setTimeout(() => this.handleContinue(), 500); setTimeout(() => this.handleContinue(), 500);
@ -323,12 +371,12 @@ export class PermissionHeader extends LitElement {
} }
async handleMicrophoneClick() { 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...'); console.log('[PermissionHeader] Requesting microphone permission...');
try { try {
const result = await window.api.permissions.checkSystemPermissions(); const result = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Microphone permission result:', result); console.log('[PermissionHeader] Microphone permission result:', result);
if (result.microphone === 'granted') { if (result.microphone === 'granted') {
@ -338,7 +386,7 @@ export class PermissionHeader extends LitElement {
} }
if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') { if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
const res = await window.api.permissions.requestMicrophonePermission(); const res = await window.api.permissionHeader.requestMicrophonePermission();
if (res.status === 'granted' || res.success === true) { if (res.status === 'granted' || res.success === true) {
this.microphoneGranted = 'granted'; this.microphoneGranted = 'granted';
this.requestUpdate(); this.requestUpdate();
@ -355,12 +403,12 @@ export class PermissionHeader extends LitElement {
} }
async handleScreenClick() { 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...'); console.log('[PermissionHeader] Checking screen recording permission...');
try { try {
const permissions = await window.api.permissions.checkSystemPermissions(); const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Screen permission check result:', permissions); console.log('[PermissionHeader] Screen permission check result:', permissions);
if (permissions.screen === 'granted') { if (permissions.screen === 'granted') {
@ -370,7 +418,7 @@ export class PermissionHeader extends LitElement {
} }
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') { if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
console.log('[PermissionHeader] Opening screen recording preferences...'); console.log('[PermissionHeader] Opening screen recording preferences...');
await window.api.permissions.openSystemPreferences('screen-recording'); await window.api.permissionHeader.openSystemPreferences('screen-recording');
} }
// Check permissions again after a delay // 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() { async handleContinue() {
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
if (this.continueCallback && if (this.continueCallback &&
this.microphoneGranted === 'granted' && this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted') { this.screenGranted === 'granted' &&
keychainOk) {
// Mark permissions as completed // Mark permissions as completed
if (window.api && window.api.permissions) { if (window.api && isKeychainRequired) {
try { try {
await window.api.permissions.markPermissionsCompleted(); await window.api.permissionHeader.markKeychainCompleted();
console.log('[PermissionHeader] Marked permissions as completed'); console.log('[PermissionHeader] Marked keychain as completed');
} catch (error) { } 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() { handleClose() {
console.log('Close button clicked'); console.log('Close button clicked');
if (window.api && window.api.permissions) { if (window.api) {
window.api.permissions.quitApplication(); window.api.common.quitApplication();
} }
} }
render() { 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` return html`
<div class="container"> <div class="container" style="height: ${containerHeight}px">
<button class="close-button" @click=${this.handleClose} title="Close application"> <button class="close-button" @click=${this.handleClose} title="Close application">
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor"> <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" /> <path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
@ -418,65 +491,92 @@ export class PermissionHeader extends LitElement {
</button> </button>
<h1 class="title">Permission Setup Required</h1> <h1 class="title">Permission Setup Required</h1>
<div class="form-content"> <div class="form-content ${allGranted ? 'all-granted' : ''}">
<div class="subtitle">Grant access to microphone and screen recording to continue</div> ${!allGranted ? html`
<div class="subtitle">Grant access to microphone, screen recording${isKeychainRequired ? ' and keychain' : ''} 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="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}"> <div class="permission-status">
${this.screenGranted === 'granted' ? html` <div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor"> ${this.microphoneGranted === 'granted' ? html`
<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 class="check-icon" viewBox="0 0 20 20" fill="currentColor">
</svg> <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" />
<span>Screen </span> </svg>
` : html` <span>Microphone </span>
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor"> ` : html`
<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 class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
</svg> <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" />
<span>Screen Recording</span> </svg>
`} <span>Microphone</span>
</div> `}
</div> </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 <button
class="action-button" class="action-button"
@click=${this.handleMicrophoneClick} @click=${this.handleMicrophoneClick}
?disabled=${this.microphoneGranted === 'granted'}
> >
Grant Microphone Access ${this.microphoneGranted === 'granted' ? 'Microphone Access Granted' : 'Grant Microphone Access'}
</button> </button>
` : ''}
${this.screenGranted !== 'granted' ? html`
<button <button
class="action-button" class="action-button"
@click=${this.handleScreenClick} @click=${this.handleScreenClick}
?disabled=${this.screenGranted === 'granted'}
> >
Grant Screen Recording Access ${this.screenGranted === 'granted' ? 'Screen Recording Granted' : 'Grant Screen Recording Access'}
</button> </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 <button
class="continue-button" class="continue-button"
@click=${this.handleContinue} @click=${this.handleContinue}
> >
Continue to Pickle Glass Continue to Pickle Glass
</button> </button>
` : ''} `}
</div> </div>
</div> </div>
`; `;

View File

@ -74,30 +74,21 @@ export class PickleGlassApp extends LitElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (window.api && window.api.app) { if (window.api) {
window.api.app.onClickThroughToggled((_, isEnabled) => { window.api.pickleGlassApp.onClickThroughToggled((_, isEnabled) => {
this._isClickThrough = isEnabled; this._isClickThrough = isEnabled;
}); });
// window.api.app.onStartListeningSession(() => {
// console.log('Received start-listening-session command, calling handleListenClick.');
// this.handleListenClick();
// });
} }
} }
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (window.api && window.api.app) { if (window.api) {
window.api.app.removeAllListeners('click-through-toggled'); window.api.pickleGlassApp.removeAllClickThroughListeners();
// window.api.app.removeAllListeners('start-listening-session');
} }
} }
updated(changedProperties) { updated(changedProperties) {
// if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) {
// this.requestWindowResize();
// }
if (changedProperties.has('currentView')) { if (changedProperties.has('currentView')) {
const viewContainer = this.shadowRoot?.querySelector('.view-container'); const viewContainer = this.shadowRoot?.querySelector('.view-container');
if (viewContainer) { if (viewContainer) {
@ -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() { async handleClose() {
if (window.api && window.api.app) { if (window.api) {
await window.api.app.quitApplication(); 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> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" /> <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
<title>Pickle Glass Content</title> <title>Pickle Glass Content</title>
<style> <style>
:root { :root {
@ -98,133 +98,6 @@
contain: layout style paint; contain: layout style paint;
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out; 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> </style>
</head> </head>
<body> <body>
@ -237,65 +110,7 @@
<script> <script>
window.addEventListener('DOMContentLoaded', () => { window.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('pickle-glass'); 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>
<script> <script>

View File

@ -1,7 +1,7 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" /> <meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
<title>Pickle Glass Header</title> <title>Pickle Glass Header</title>
<style> <style>
html, html,

View File

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

View File

@ -1,4 +1,3 @@
const { ipcRenderer } = require('electron');
const createAecModule = require('./aec.js'); const createAecModule = require('./aec.js');
let aecModPromise = null; // 한 번만 로드 let aecModPromise = null; // 한 번만 로드
@ -34,18 +33,15 @@ const SAMPLE_RATE = 24000;
const AUDIO_CHUNK_DURATION = 0.1; const AUDIO_CHUNK_DURATION = 0.1;
const BUFFER_SIZE = 4096; const BUFFER_SIZE = 4096;
const isLinux = process.platform === 'linux'; const isLinux = window.api.platform.isLinux;
const isMacOS = process.platform === 'darwin'; const isMacOS = window.api.platform.isMacOS;
let mediaStream = null; let mediaStream = null;
let micMediaStream = null; let micMediaStream = null;
let screenshotInterval = null;
let audioContext = null; let audioContext = null;
let audioProcessor = null; let audioProcessor = null;
let systemAudioContext = null; let systemAudioContext = null;
let systemAudioProcessor = null; let systemAudioProcessor = null;
let currentImageQuality = 'medium';
let lastScreenshotBase64 = null;
let systemAudioBuffer = []; let systemAudioBuffer = [];
const MAX_SYSTEM_BUFFER_SIZE = 10; const MAX_SYSTEM_BUFFER_SIZE = 10;
@ -141,10 +137,6 @@ function runAecSync(micF32, sysF32) {
return micF32; return micF32;
} }
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
// 새로운 프레임 단위 처리 로직
// ▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼▼
const frameSize = 160; // AEC 모듈 초기화 시 설정한 프레임 크기 const frameSize = 160; // AEC 모듈 초기화 시 설정한 프레임 크기
const numFrames = Math.floor(micF32.length / frameSize); const numFrames = Math.floor(micF32.length / frameSize);
@ -198,7 +190,7 @@ function runAecSync(micF32, sysF32) {
// System audio data handler // System audio data handler
window.api.audio.onSystemAudioData((event, { data }) => { window.api.listenCapture.onSystemAudioData((event, { data }) => {
systemAudioBuffer.push({ systemAudioBuffer.push({
data: data, data: data,
timestamp: Date.now(), timestamp: Date.now(),
@ -336,7 +328,7 @@ async function setupMicProcessing(micStream) {
const pcm16 = convertFloat32ToInt16(processedChunk); const pcm16 = convertFloat32ToInt16(processedChunk);
const b64 = arrayBufferToBase64(pcm16.buffer); const b64 = arrayBufferToBase64(pcm16.buffer);
window.api.audio.sendAudioContent({ window.api.listenCapture.sendMicAudioContent({
data: b64, data: b64,
mimeType: 'audio/pcm;rate=24000', mimeType: 'audio/pcm;rate=24000',
}); });
@ -369,7 +361,7 @@ function setupLinuxMicProcessing(micStream) {
const pcmData16 = convertFloat32ToInt16(chunk); const pcmData16 = convertFloat32ToInt16(chunk);
const base64Data = arrayBufferToBase64(pcmData16.buffer); const base64Data = arrayBufferToBase64(pcmData16.buffer);
await window.api.audio.sendAudioContent({ await window.api.listenCapture.sendMicAudioContent({
data: base64Data, data: base64Data,
mimeType: 'audio/pcm;rate=24000', mimeType: 'audio/pcm;rate=24000',
}); });
@ -403,7 +395,7 @@ function setupSystemAudioProcessing(systemStream) {
const base64Data = arrayBufferToBase64(pcmData16.buffer); const base64Data = arrayBufferToBase64(pcmData16.buffer);
try { try {
await window.api.audio.sendSystemAudioContent({ await window.api.listenCapture.sendSystemAudioContent({
data: base64Data, data: base64Data,
mimeType: 'audio/pcm;rate=24000', mimeType: 'audio/pcm;rate=24000',
}); });
@ -419,94 +411,10 @@ function setupSystemAudioProcessing(systemStream) {
return { context: systemAudioContext, processor: systemProcessor }; 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) // Main capture functions (exact from renderer.js)
// --------------------------- // ---------------------------
async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'medium') { async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'medium') {
// Store the image quality for manual screenshots
currentImageQuality = imageQuality;
// Reset token tracker when starting new capture session // Reset token tracker when starting new capture session
tokenTracker.reset(); tokenTracker.reset();
@ -514,19 +422,25 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
try { try {
if (isMacOS) { 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 // On macOS, use SystemAudioDump for audio and getDisplayMedia for screen
console.log('Starting macOS capture with SystemAudioDump...'); console.log('Starting macOS capture with SystemAudioDump...');
// Start macOS audio capture // Start macOS audio capture
const audioResult = await window.api.audio.startMacosAudio(); const audioResult = await window.api.listenCapture.startMacosSystemAudio();
if (!audioResult.success) { if (!audioResult.success) {
console.warn('[listenCapture] macOS audio start failed:', audioResult.error); console.warn('[listenCapture] macOS audio start failed:', audioResult.error);
// 이미 실행 중 → stop 후 재시도 // 이미 실행 중 → stop 후 재시도
if (audioResult.error === 'already_running') { if (audioResult.error === 'already_running') {
await window.api.audio.stopMacosAudio(); await window.api.listenCapture.stopMacosSystemAudio();
await new Promise(r => setTimeout(r, 500)); await new Promise(r => setTimeout(r, 500));
const retry = await window.api.audio.startMacosAudio(); const retry = await window.api.listenCapture.startMacosSystemAudio();
if (!retry.success) { if (!retry.success) {
throw new Error('Retry failed: ' + retry.error); 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 { try {
micMediaStream = await navigator.mediaDevices.getUserMedia({ micMediaStream = await navigator.mediaDevices.getUserMedia({
audio: { audio: {
@ -565,6 +472,12 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
console.log('macOS screen capture started - audio handled by SystemAudioDump'); console.log('macOS screen capture started - audio handled by SystemAudioDump');
} else if (isLinux) { } 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 // Linux - use display media for screen capture and getUserMedia for microphone
mediaStream = await navigator.mediaDevices.getDisplayMedia({ mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: { video: {
@ -603,14 +516,8 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
// Windows - capture mic and system audio separately using native loopback // Windows - capture mic and system audio separately using native loopback
console.log('Starting Windows capture with native loopback audio...'); 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 // 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) { if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete'); 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 // 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) { } catch (err) {
console.error('Error starting capture:', err); console.error('Error starting capture:', err);
// Note: pickleGlass.e() is not available in this context, commenting out // Note: pickleGlass.e() is not available in this context, commenting out
@ -679,11 +572,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
} }
function stopCapture() { function stopCapture() {
if (screenshotInterval) {
clearInterval(screenshotInterval);
screenshotInterval = null;
}
// Clean up microphone resources // Clean up microphone resources
if (audioProcessor) { if (audioProcessor) {
audioProcessor.disconnect(); audioProcessor.disconnect();
@ -714,14 +602,9 @@ function stopCapture() {
micMediaStream = null; 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 // Stop macOS audio capture if running
if (isMacOS) { if (isMacOS) {
window.api.audio.stopMacosAudio().catch(err => { window.api.listenCapture.stopMacosSystemAudio().catch(err => {
console.error('Error stopping macOS audio:', err); console.error('Error stopping macOS audio:', err);
}); });
} }
@ -736,19 +619,14 @@ module.exports = {
disposeAec, // 필요시 Rust 객체 파괴 disposeAec, // 필요시 Rust 객체 파괴
startCapture, startCapture,
stopCapture, stopCapture,
captureManualScreenshot,
getCurrentScreenshot,
isLinux, isLinux,
isMacOS, isMacOS,
}; };
// Expose functions to global scope for external access (exact from renderer.js) // Expose functions to global scope for external access (exact from renderer.js)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.captureManualScreenshot = captureManualScreenshot;
window.listenCapture = module.exports; window.listenCapture = module.exports;
window.pickleGlass = window.pickleGlass || {}; window.pickleGlass = window.pickleGlass || {};
window.pickleGlass.startCapture = startCapture; window.pickleGlass.startCapture = startCapture;
window.pickleGlass.stopCapture = stopCapture; window.pickleGlass.stopCapture = stopCapture;
window.pickleGlass.captureManualScreenshot = captureManualScreenshot;
window.pickleGlass.getCurrentScreenshot = getCurrentScreenshot;
} }

View File

@ -1,5 +1,4 @@
// renderer.js // renderer.js
const { ipcRenderer } = require('electron');
const listenCapture = require('./listenCapture.js'); const listenCapture = require('./listenCapture.js');
const params = new URLSearchParams(window.location.search); const params = new URLSearchParams(window.location.search);
const isListenView = params.get('view') === 'listen'; 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) { if (!isListenView) {
console.log('[Renderer] Non-listen view: ignoring capture-state change'); console.log('[Renderer] Non-listen view: ignoring capture-state change');
return; return;

View File

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

View File

@ -262,8 +262,8 @@ export class SummaryView extends LitElement {
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
if (window.api && window.api.listen) { if (window.api) {
window.api.listen.onSummaryUpdate((event, data) => { window.api.summaryView.onSummaryUpdate((event, data) => {
this.structuredData = data; this.structuredData = data;
this.requestUpdate(); this.requestUpdate();
}); });
@ -272,8 +272,8 @@ export class SummaryView extends LitElement {
disconnectedCallback() { disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
if (window.api && window.api.listen) { if (window.api) {
window.api.listen.removeOnSummaryUpdate(() => {}); window.api.summaryView.removeAllSummaryUpdateListeners();
} }
} }
@ -406,9 +406,9 @@ export class SummaryView extends LitElement {
async handleRequestClick(requestText) { async handleRequestClick(requestText) {
console.log('🔥 Analysis request clicked:', requestText); console.log('🔥 Analysis request clicked:', requestText);
if (window.api && window.api.listen) { if (window.api) {
try { try {
const result = await ipcRenderer.invoke('ask:sendQuestionToMain', requestText); const result = await window.api.summaryView.sendQuestionFromSummary(requestText);
if (result.success) { if (result.success) {
console.log('✅ Question sent to AskView successfully'); 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 { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; // import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; // 제거됨
export class SettingsView extends LitElement { export class SettingsView extends LitElement {
static styles = css` static styles = css`
@ -531,7 +531,6 @@ export class SettingsView extends LitElement {
this.ollamaStatus = { installed: false, running: false }; this.ollamaStatus = { installed: false, running: false };
this.ollamaModels = []; this.ollamaModels = [];
this.installingModels = {}; // { modelName: progress } this.installingModels = {}; // { modelName: progress }
this.progressTracker = getOllamaProgressTracker();
// Whisper related // Whisper related
this.whisperModels = []; this.whisperModels = [];
this.whisperProgressTracker = null; // Will be initialized when needed this.whisperProgressTracker = null; // Will be initialized when needed
@ -543,9 +542,10 @@ export class SettingsView extends LitElement {
} }
async loadAutoUpdateSetting() { async loadAutoUpdateSetting() {
if (!window.api) return;
this.autoUpdateLoading = true; this.autoUpdateLoading = true;
try { try {
const enabled = await window.api.feature.settings.getAutoUpdate(); const enabled = await window.api.settingsView.getAutoUpdate();
this.autoUpdateEnabled = enabled; this.autoUpdateEnabled = enabled;
console.log('Auto-update setting loaded:', enabled); console.log('Auto-update setting loaded:', enabled);
} catch (e) { } catch (e) {
@ -557,12 +557,12 @@ export class SettingsView extends LitElement {
} }
async handleToggleAutoUpdate() { async handleToggleAutoUpdate() {
if (this.autoUpdateLoading) return; if (!window.api || this.autoUpdateLoading) return;
this.autoUpdateLoading = true; this.autoUpdateLoading = true;
this.requestUpdate(); this.requestUpdate();
try { try {
const newValue = !this.autoUpdateEnabled; 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) { if (result && result.success) {
this.autoUpdateEnabled = newValue; this.autoUpdateEnabled = newValue;
} else { } else {
@ -575,31 +575,64 @@ export class SettingsView extends LitElement {
this.requestUpdate(); 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 //////// //////// after_modelStateService ////////
async loadInitialData() { async loadInitialData() {
if (!window.api) return;
this.isLoading = true; this.isLoading = true;
try { try {
const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = await Promise.all([ // Load essential data first
window.api.feature.settings.getCurrentUser(), const [userState, modelSettings, presets, contentProtection, shortcuts] = await Promise.all([
window.api.feature.settings.getProviderConfig(), // Provider 설정 로드 window.api.settingsView.getCurrentUser(),
window.api.feature.settings.getAllKeys(), window.api.settingsView.getModelSettings(), // Facade call
window.api.feature.settings.getAvailableModels({ type: 'llm' }), window.api.settingsView.getPresets(),
window.api.feature.settings.getAvailableModels({ type: 'stt' }), window.api.settingsView.getContentProtectionStatus(),
window.api.feature.settings.getSelectedModels(), window.api.settingsView.getCurrentShortcuts()
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()
]); ]);
if (userState && userState.isLoggedIn) this.firebaseUser = userState; if (userState && userState.isLoggedIn) this.firebaseUser = userState;
this.providerConfig = config;
this.apiKeys = storedKeys; if (modelSettings.success) {
this.availableLlmModels = availableLlm; const { config, storedKeys, availableLlm, availableStt, selectedModels } = modelSettings.data;
this.availableSttModels = availableStt; this.providerConfig = config;
this.selectedLlm = selectedModels.llm; this.apiKeys = storedKeys;
this.selectedStt = selectedModels.stt; this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt;
this.selectedLlm = selectedModels.llm;
this.selectedStt = selectedModels.stt;
}
this.presets = presets || []; this.presets = presets || [];
this.isContentProtectionOn = contentProtection; this.isContentProtectionOn = contentProtection;
this.shortcuts = shortcuts || {}; this.shortcuts = shortcuts || {};
@ -607,23 +640,9 @@ export class SettingsView extends LitElement {
const firstUserPreset = this.presets.find(p => p.is_default === 0); const firstUserPreset = this.presets.find(p => p.is_default === 0);
if (firstUserPreset) this.selectedPreset = firstUserPreset; if (firstUserPreset) this.selectedPreset = firstUserPreset;
} }
// Ollama status
if (ollamaStatus?.success) { // Load LocalAI status asynchronously to improve initial load time
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running }; this.loadLocalAIStatus();
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;
}
});
}
}
} catch (error) { } catch (error) {
console.error('Error loading initial settings data:', error); console.error('Error loading initial settings data:', error);
} finally { } finally {
@ -631,6 +650,7 @@ export class SettingsView extends LitElement {
} }
} }
async handleSaveKey(provider) { async handleSaveKey(provider) {
const input = this.shadowRoot.querySelector(`#key-input-${provider}`); const input = this.shadowRoot.querySelector(`#key-input-${provider}`);
if (!input) return; if (!input) return;
@ -641,7 +661,7 @@ export class SettingsView extends LitElement {
this.saving = true; this.saving = true;
// First ensure Ollama is installed and running // 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) { if (!ensureResult.success) {
alert(`Failed to setup Ollama: ${ensureResult.error}`); alert(`Failed to setup Ollama: ${ensureResult.error}`);
this.saving = false; this.saving = false;
@ -649,10 +669,9 @@ export class SettingsView extends LitElement {
} }
// Now validate (which will check if service is running) // 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) { if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: 'local' };
await this.refreshModelData(); await this.refreshModelData();
await this.refreshOllamaStatus(); await this.refreshOllamaStatus();
} else { } else {
@ -665,10 +684,9 @@ export class SettingsView extends LitElement {
// For Whisper, just enable it // For Whisper, just enable it
if (provider === 'whisper') { if (provider === 'whisper') {
this.saving = true; 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) { if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: 'local' };
await this.refreshModelData(); await this.refreshModelData();
} else { } else {
alert(`Failed to enable Whisper: ${result.error}`); alert(`Failed to enable Whisper: ${result.error}`);
@ -679,10 +697,9 @@ export class SettingsView extends LitElement {
// For other providers, use the normal flow // For other providers, use the normal flow
this.saving = true; 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) { if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: key };
await this.refreshModelData(); await this.refreshModelData();
} else { } else {
alert(`Failed to save ${provider} key: ${result.error}`); alert(`Failed to save ${provider} key: ${result.error}`);
@ -692,8 +709,9 @@ export class SettingsView extends LitElement {
} }
async handleClearKey(provider) { async handleClearKey(provider) {
console.log(`[SettingsView] handleClearKey: ${provider}`);
this.saving = true; this.saving = true;
await window.api.feature.settings.removeApiKey({ provider }); await window.api.settingsView.removeApiKey(provider);
this.apiKeys = { ...this.apiKeys, [provider]: '' }; this.apiKeys = { ...this.apiKeys, [provider]: '' };
await this.refreshModelData(); await this.refreshModelData();
this.saving = false; this.saving = false;
@ -701,10 +719,10 @@ export class SettingsView extends LitElement {
async refreshModelData() { async refreshModelData() {
const [availableLlm, availableStt, selected, storedKeys] = await Promise.all([ const [availableLlm, availableStt, selected, storedKeys] = await Promise.all([
window.api.feature.settings.getAvailableModels({ type: 'llm' }), window.api.settingsView.getAvailableModels({ type: 'llm' }),
window.api.feature.settings.getAvailableModels({ type: 'stt' }), window.api.settingsView.getAvailableModels({ type: 'stt' }),
window.api.feature.settings.getSelectedModels(), window.api.settingsView.getSelectedModels(),
window.api.feature.settings.getAllKeys() window.api.settingsView.getAllKeys()
]); ]);
this.availableLlmModels = availableLlm; this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt; this.availableSttModels = availableStt;
@ -755,7 +773,7 @@ export class SettingsView extends LitElement {
} }
this.saving = true; 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 === 'llm') this.selectedLlm = modelId;
if (type === 'stt') this.selectedStt = modelId; if (type === 'stt') this.selectedStt = modelId;
this.isLlmListVisible = false; this.isLlmListVisible = false;
@ -765,7 +783,7 @@ export class SettingsView extends LitElement {
} }
async refreshOllamaStatus() { async refreshOllamaStatus() {
const ollamaStatus = await window.api.feature.settings.getOllamaStatus(); const ollamaStatus = await window.api.settingsView.getOllamaStatus();
if (ollamaStatus?.success) { if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running }; this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || []; this.ollamaModels = ollamaStatus.models || [];
@ -773,31 +791,42 @@ export class SettingsView extends LitElement {
} }
async installOllamaModel(modelName) { async installOllamaModel(modelName) {
// Mark as installing
this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.requestUpdate();
try { try {
// Use the clean progress tracker - no manual event management needed // Ollama 모델 다운로드 시작
const success = await this.progressTracker.installModel(modelName, (progress) => { this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.installingModels = { ...this.installingModels, [modelName]: progress }; this.requestUpdate();
this.requestUpdate();
}); // 진행률 이벤트 리스너 설정 - 통합 LocalAI 이벤트 사용
const progressHandler = (event, data) => {
if (success) { if (data.service === 'ollama' && data.model === modelName) {
// Refresh status after installation this.installingModels = { ...this.installingModels, [modelName]: data.progress || 0 };
await this.refreshOllamaStatus(); this.requestUpdate();
await this.refreshModelData(); }
// Auto-select the model after installation };
await this.selectModel('llm', modelName);
} else { // 통합 LocalAI 이벤트 리스너 등록
alert(`Installation of ${modelName} was cancelled`); 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) { } catch (error) {
console.error(`[SettingsView] Error installing model ${modelName}:`, error); console.error(`[SettingsView] Error installing model ${modelName}:`, error);
alert(`Error installing ${modelName}: ${error.message}`);
} finally {
// Automatic cleanup - no manual event listener management
delete this.installingModels[modelName]; delete this.installingModels[modelName];
this.requestUpdate(); this.requestUpdate();
} }
@ -809,34 +838,52 @@ export class SettingsView extends LitElement {
this.requestUpdate(); this.requestUpdate();
try { try {
// Set up progress listener // Set up progress listener - 통합 LocalAI 이벤트 사용
const progressHandler = (event, { modelId: id, progress }) => { const progressHandler = (event, data) => {
if (id === modelId) { if (data.service === 'whisper' && data.model === modelId) {
this.installingModels = { ...this.installingModels, [modelId]: progress }; this.installingModels = { ...this.installingModels, [modelId]: data.progress || 0 };
this.requestUpdate(); this.requestUpdate();
} }
}; };
window.api.feature.settings.onWhisperDownloadProgress(progressHandler); window.api.settingsView.onLocalAIInstallProgress(progressHandler);
// Start download // Start download
const result = await window.api.feature.settings.downloadWhisperModel(modelId); const result = await window.api.settingsView.downloadWhisperModel(modelId);
if (result.success) { 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 // Auto-select the model after download
await this.selectModel('stt', modelId); await this.selectModel('stt', modelId);
} else { } else {
// Remove from installing models on failure too
delete this.installingModels[modelId];
this.requestUpdate();
alert(`Failed to download Whisper model: ${result.error}`); alert(`Failed to download Whisper model: ${result.error}`);
} }
// Cleanup // Cleanup
window.api.feature.settings.removeOnWhisperDownloadProgress(progressHandler); window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
} catch (error) { } catch (error) {
console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error); console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error);
alert(`Error downloading ${modelId}: ${error.message}`); // Remove from installing models on error
} finally {
delete this.installingModels[modelId]; delete this.installingModels[modelId];
this.requestUpdate(); this.requestUpdate();
alert(`Error downloading ${modelId}: ${error.message}`);
} }
} }
@ -850,24 +897,18 @@ export class SettingsView extends LitElement {
return null; return null;
} }
async handleWhisperModelSelect(modelId) {
if (!modelId) return;
// Select the model (will trigger download if needed)
await this.selectModel('stt', modelId);
}
handleUsePicklesKey(e) { handleUsePicklesKey(e) {
e.preventDefault() e.preventDefault()
if (this.wasJustDragged) return if (this.wasJustDragged) return
console.log("Requesting Firebase authentication from main process...") console.log("Requesting Firebase authentication from main process...")
window.api.feature.settings.startFirebaseAuth(); window.api.settingsView.startFirebaseAuth();
} }
//////// after_modelStateService //////// //////// after_modelStateService ////////
openShortcutEditor() { openShortcutEditor() {
window.api.feature.settings.openShortcutEditor(); window.api.settingsView.openShortcutSettingsWindow();
} }
connectedCallback() { connectedCallback() {
@ -877,6 +918,8 @@ export class SettingsView extends LitElement {
this.setupIpcListeners(); this.setupIpcListeners();
this.setupWindowResize(); this.setupWindowResize();
this.loadAutoUpdateSetting(); this.loadAutoUpdateSetting();
// Force one height calculation immediately (innerHeight may be 0 at first)
setTimeout(() => this.updateScrollHeight(), 0);
} }
disconnectedCallback() { disconnectedCallback() {
@ -889,7 +932,7 @@ export class SettingsView extends LitElement {
const installingModels = Object.keys(this.installingModels); const installingModels = Object.keys(this.installingModels);
if (installingModels.length > 0) { if (installingModels.length > 0) {
installingModels.forEach(modelName => { installingModels.forEach(modelName => {
this.progressTracker.cancelInstallation(modelName); window.api.settingsView.cancelOllamaInstallation(modelName);
}); });
} }
} }
@ -905,6 +948,8 @@ export class SettingsView extends LitElement {
} }
setupIpcListeners() { setupIpcListeners() {
if (!window.api) return;
this._userStateListener = (event, userState) => { this._userStateListener = (event, userState) => {
console.log('[SettingsView] Received user-state-changed:', userState); console.log('[SettingsView] Received user-state-changed:', userState);
if (userState && userState.isLoggedIn) { if (userState && userState.isLoggedIn) {
@ -913,7 +958,8 @@ export class SettingsView extends LitElement {
this.firebaseUser = null; this.firebaseUser = null;
} }
this.loadAutoUpdateSetting(); this.loadAutoUpdateSetting();
this.requestUpdate(); // Reload model settings when user state changes (Firebase login/logout)
this.loadInitialData();
}; };
this._settingsUpdatedListener = (event, settings) => { this._settingsUpdatedListener = (event, settings) => {
@ -926,7 +972,7 @@ export class SettingsView extends LitElement {
this._presetsUpdatedListener = async (event) => { this._presetsUpdatedListener = async (event) => {
console.log('[SettingsView] Received presets-updated, refreshing presets'); console.log('[SettingsView] Received presets-updated, refreshing presets');
try { try {
const presets = await window.api.feature.settings.getPresets(); const presets = await window.api.settingsView.getPresets();
this.presets = presets || []; this.presets = presets || [];
// 현재 선택된 프리셋이 삭제되었는지 확인 (사용자 프리셋만 고려) // 현재 선택된 프리셋이 삭제되었는지 확인 (사용자 프리셋만 고려)
@ -945,24 +991,26 @@ export class SettingsView extends LitElement {
this.shortcuts = keybinds; this.shortcuts = keybinds;
}; };
window.api.feature.settings.onUserStateChanged(this._userStateListener); window.api.settingsView.onUserStateChanged(this._userStateListener);
window.api.feature.settings.onSettingsUpdated(this._settingsUpdatedListener); window.api.settingsView.onSettingsUpdated(this._settingsUpdatedListener);
window.api.feature.settings.onPresetsUpdated(this._presetsUpdatedListener); window.api.settingsView.onPresetsUpdated(this._presetsUpdatedListener);
window.api.feature.settings.onShortcutsUpdated(this._shortcutListener); window.api.settingsView.onShortcutsUpdated(this._shortcutListener);
} }
cleanupIpcListeners() { cleanupIpcListeners() {
if (!window.api) return;
if (this._userStateListener) { if (this._userStateListener) {
window.api.feature.settings.removeOnUserStateChanged(this._userStateListener); window.api.settingsView.removeOnUserStateChanged(this._userStateListener);
} }
if (this._settingsUpdatedListener) { if (this._settingsUpdatedListener) {
window.api.feature.settings.removeOnSettingsUpdated(this._settingsUpdatedListener); window.api.settingsView.removeOnSettingsUpdated(this._settingsUpdatedListener);
} }
if (this._presetsUpdatedListener) { if (this._presetsUpdatedListener) {
window.api.feature.settings.removeOnPresetsUpdated(this._presetsUpdatedListener); window.api.settingsView.removeOnPresetsUpdated(this._presetsUpdatedListener);
} }
if (this._shortcutListener) { 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() { updateScrollHeight() {
const windowHeight = window.innerHeight; // Electron 일부 시점에서 window.innerHeight 가 0 으로 보고되는 버그 보호
const maxHeight = windowHeight; 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`; this.style.maxHeight = `${maxHeight}px`;
const container = this.shadowRoot?.querySelector('.settings-container'); const container = this.shadowRoot?.querySelector('.settings-container');
if (container) { if (container) {
container.style.maxHeight = `${maxHeight}px`; container.style.maxHeight = `${maxHeight}px`;
@ -996,20 +1046,16 @@ export class SettingsView extends LitElement {
} }
handleMouseEnter = () => { handleMouseEnter = () => {
window.api.window.cancelHideSettingsWindow(); window.api.settingsView.cancelHideSettingsWindow();
// Recalculate height in case it was set to 0 before
this.updateScrollHeight();
} }
handleMouseLeave = () => { 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() { getMainShortcuts() {
return [ return [
{ name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility }, { name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility },
@ -1050,18 +1096,18 @@ export class SettingsView extends LitElement {
handleMoveLeft() { handleMoveLeft() {
console.log('Move Left clicked'); console.log('Move Left clicked');
window.api.feature.settings.moveWindowStep('left'); window.api.settingsView.moveWindowStep('left');
} }
handleMoveRight() { handleMoveRight() {
console.log('Move Right clicked'); console.log('Move Right clicked');
window.api.feature.settings.moveWindowStep('right'); window.api.settingsView.moveWindowStep('right');
} }
async handlePersonalize() { async handlePersonalize() {
console.log('Personalize clicked'); console.log('Personalize clicked');
try { try {
await window.api.window.openLoginPage(); await window.api.settingsView.openPersonalizePage();
} catch (error) { } catch (error) {
console.error('Failed to open personalize page:', error); console.error('Failed to open personalize page:', error);
} }
@ -1069,7 +1115,7 @@ export class SettingsView extends LitElement {
async handleToggleInvisibility() { async handleToggleInvisibility() {
console.log('Toggle Invisibility clicked'); console.log('Toggle Invisibility clicked');
this.isContentProtectionOn = await window.api.window.toggleContentProtection(); this.isContentProtectionOn = await window.api.settingsView.toggleContentProtection();
this.requestUpdate(); this.requestUpdate();
} }
@ -1079,7 +1125,7 @@ export class SettingsView extends LitElement {
const newApiKey = input.value; const newApiKey = input.value;
try { try {
const result = await window.api.feature.settings.saveApiKey(newApiKey); const result = await window.api.settingsView.saveApiKey(newApiKey);
if (result.success) { if (result.success) {
console.log('API Key saved successfully via IPC.'); console.log('API Key saved successfully via IPC.');
this.apiKey = newApiKey; 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() { handleQuit() {
console.log('Quit clicked'); console.log('Quit clicked');
window.api.window.quitApplication(); window.api.settingsView.quitApplication();
} }
handleFirebaseLogout() { handleFirebaseLogout() {
console.log('Firebase Logout clicked'); console.log('Firebase Logout clicked');
window.api.window.firebaseLogout(); window.api.settingsView.firebaseLogout();
} }
async handleOllamaShutdown() { async handleOllamaShutdown() {
console.log('[SettingsView] Shutting down Ollama service...'); console.log('[SettingsView] Shutting down Ollama service...');
if (!window.api) return;
try { try {
// Show loading state // Show loading state
this.ollamaStatus = { ...this.ollamaStatus, running: false }; this.ollamaStatus = { ...this.ollamaStatus, running: false };
this.requestUpdate(); 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) { if (result.success) {
console.log('[SettingsView] Ollama shut down successfully'); 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 //////// const loggedIn = !!this.firebaseUser;
// 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 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` const modelSelectionHTML = html`
// <div class="settings-container"> <div class="model-selection-section">
// <div class="header-section"> <div class="model-select-group">
// <div> <label>LLM Model: <strong>${getModelName('llm', this.selectedLlm) || 'Not Set'}</strong></label>
// <h1 class="app-title">Pickle Glass</h1> <button class="settings-button full-width" @click=${() => this.toggleModelList('llm')} ?disabled=${this.saving || this.availableLlmModels.length === 0}>
// <div class="account-info"> Change LLM Model
// ${this.firebaseUser </button>
// ? html`Account: ${this.firebaseUser.email || 'Logged In'}` ${this.isLlmListVisible ? html`
// : this.apiKey && this.apiKey.length > 10 <div class="model-list">
// ? html`API Key: ${this.apiKey.substring(0, 6)}...${this.apiKey.substring(this.apiKey.length - 6)}` ${this.availableLlmModels.map(model => {
// : `Account: Not Logged In` const isOllama = this.getProviderForModel('llm', model.id) === 'ollama';
// } const ollamaModel = isOllama ? this.ollamaModels.find(m => m.name === model.id) : null;
// </div> const isInstalling = this.installingModels[model.id] !== undefined;
// </div> const installProgress = this.installingModels[model.id] || 0;
// <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"> return html`
// <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"/> <div class="model-item ${this.selectedLlm === model.id ? 'selected' : ''}"
// </svg> @click=${() => this.selectModel('llm', model.id)}>
// </div> <span>${model.name}</span>
// </div> ${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"> return html`
// <input <div class="settings-container">
// type="password" <div class="header-section">
// id="api-key-input" <div>
// placeholder="Enter API Key" <h1 class="app-title">Pickle Glass</h1>
// .value=${this.apiKey || ''} <div class="account-info">
// ?disabled=${loggedIn} ${this.firebaseUser
// > ? html`Account: ${this.firebaseUser.email || 'Logged In'}`
// <button class="settings-button full-width" @click=${this.handleSaveApiKey} ?disabled=${loggedIn}> : `Account: Not Logged In`
// Save API Key }
// </button> </div>
// </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"> ${apiKeyManagementHTML}
// ${this.getMainShortcuts().map(shortcut => html` ${modelSelectionHTML}
// <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>
// <!-- Preset Management Section --> <div class="buttons-section" style="border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 6px; margin-top: 6px;">
// <div class="preset-section"> <button class="settings-button full-width" @click=${this.openShortcutEditor}>
// <div class="preset-header"> Edit Shortcuts
// <span class="preset-title"> </button>
// My Presets </div>
// <span class="preset-count">(${this.presets.filter(p => p.is_default === 0).length})</span>
// </span>
// <span class="preset-toggle" @click=${this.togglePresets}> <div class="shortcuts-section">
// ${this.showPresets ? '▼' : '▶'} ${this.getMainShortcuts().map(shortcut => html`
// </span> <div class="shortcut-item">
// </div> <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'}"> <div class="preset-list ${this.showPresets ? '' : 'hidden'}">
// ${this.presets.filter(p => p.is_default === 0).length === 0 ? html` ${this.presets.filter(p => p.is_default === 0).length === 0 ? html`
// <div class="no-presets-message"> <div class="no-presets-message">
// No custom presets yet.<br> No custom presets yet.<br>
// <span class="web-link" @click=${this.handlePersonalize}> <span class="web-link" @click=${this.handlePersonalize}>
// Create your first preset Create your first preset
// </span> </span>
// </div> </div>
// ` : this.presets.filter(p => p.is_default === 0).map(preset => html` ` : this.presets.filter(p => p.is_default === 0).map(preset => html`
// <div class="preset-item ${this.selectedPreset?.id === preset.id ? 'selected' : ''}" <div class="preset-item ${this.selectedPreset?.id === preset.id ? 'selected' : ''}"
// @click=${() => this.handlePresetSelect(preset)}> @click=${() => this.handlePresetSelect(preset)}>
// <span class="preset-name">${preset.title}</span> <span class="preset-name">${preset.title}</span>
// ${this.selectedPreset?.id === preset.id ? html`<span class="preset-status">Selected</span>` : ''} ${this.selectedPreset?.id === preset.id ? html`<span class="preset-status">Selected</span>` : ''}
// </div> </div>
// `)} `)}
// </div> </div>
// </div> </div>
// <div class="buttons-section"> <div class="buttons-section">
// <button class="settings-button full-width" @click=${this.handlePersonalize}> <button class="settings-button full-width" @click=${this.handlePersonalize}>
// <span>Personalize / Meeting Notes</span> <span>Personalize / Meeting Notes</span>
// </button> </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"> <div class="move-buttons">
// <button class="settings-button half-width" @click=${this.handleMoveLeft}> <button class="settings-button half-width" @click=${this.handleMoveLeft}>
// <span>← Move</span> <span> Move</span>
// </button> </button>
// <button class="settings-button half-width" @click=${this.handleMoveRight}> <button class="settings-button half-width" @click=${this.handleMoveRight}>
// <span>Move →</span> <span>Move </span>
// </button> </button>
// </div> </div>
// <button class="settings-button full-width" @click=${this.handleToggleInvisibility}> <button class="settings-button full-width" @click=${this.handleToggleInvisibility}>
// <span>${this.isContentProtectionOn ? 'Disable Invisibility' : 'Enable Invisibility'}</span> <span>${this.isContentProtectionOn ? 'Disable Invisibility' : 'Enable Invisibility'}</span>
// </button> </button>
// <div class="bottom-buttons"> <div class="bottom-buttons">
// ${this.firebaseUser ${this.firebaseUser
// ? html` ? html`
// <button class="settings-button half-width danger" @click=${this.handleFirebaseLogout}> <button class="settings-button half-width danger" @click=${this.handleFirebaseLogout}>
// <span>Logout</span> <span>Logout</span>
// </button> </button>
// ` `
// : html` : html`
// <button class="settings-button half-width danger" @click=${this.handleClearApiKey}> <button class="settings-button half-width" @click=${this.handleUsePicklesKey}>
// <span>Clear API Key</span> <span>Login</span>
// </button> </button>
// ` `
// } }
// <button class="settings-button half-width danger" @click=${this.handleQuit}> <button class="settings-button half-width danger" @click=${this.handleQuit}>
// <span>Quit</span> <span>Quit</span>
// </button> </button>
// </div> </div>
// </div> </div>
// </div> </div>
// `; `;
// } }
//////// before_modelStateService ////////
//////// after_modelStateService //////// //////// after_modelStateService ////////
} }

View File

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

View File

@ -1,11 +1,8 @@
const { screen } = require('electron'); const { screen } = require('electron');
class SmoothMovementManager { class SmoothMovementManager {
constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) { constructor(windowPool) {
this.windowPool = windowPool; this.windowPool = windowPool;
this.getDisplayById = getDisplayById;
this.getCurrentDisplay = getCurrentDisplay;
this.updateLayout = updateLayout;
this.stepSize = 80; this.stepSize = 80;
this.animationDuration = 300; this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 }; this.headerPosition = { x: 0, y: 0 };
@ -14,6 +11,8 @@ class SmoothMovementManager {
this.lastVisiblePosition = null; this.lastVisiblePosition = null;
this.currentDisplayId = null; this.currentDisplayId = null;
this.animationFrameId = null; this.animationFrameId = null;
this.animationTimers = new Map();
} }
/** /**
@ -22,248 +21,162 @@ class SmoothMovementManager {
*/ */
_isWindowValid(win) { _isWindowValid(win) {
if (!win || win.isDestroyed()) { if (!win || win.isDestroyed()) {
if (this.isAnimating) { // 해당 창의 타이머가 있으면 정리
console.warn('[MovementManager] Window destroyed mid-animation. Halting.'); if (this.animationTimers.has(win)) {
this.isAnimating = false; clearTimeout(this.animationTimers.get(win));
if (this.animationFrameId) { this.animationTimers.delete(win);
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
} }
return false; return false;
} }
return true; return true;
} }
moveToDisplay(displayId) { /**
const header = this.windowPool.get('header'); *
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return; * @param {BrowserWindow} win
* @param {number} targetX
const targetDisplay = this.getDisplayById(displayId); * @param {number} targetY
if (!targetDisplay) return; * @param {object} [options]
* @param {object} [options.sizeOverride]
const currentBounds = header.getBounds(); * @param {function} [options.onComplete]
const currentDisplay = this.getCurrentDisplay(header); * @param {number} [options.duration]
*/
if (currentDisplay.id === targetDisplay.id) return; animateWindow(win, targetX, targetY, options = {}) {
if (!this._isWindowValid(win)) {
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width; if (options.onComplete) options.onComplete();
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();
return; return;
} }
header.webContents.send('window-hide-animation'); const { sizeOverride, onComplete, duration: animDuration } = options;
const start = win.getBounds();
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 startTime = Date.now(); 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)) { const step = () => {
this.isAnimating = false; if (!this._isWindowValid(win)) {
return; if (onComplete) onComplete();
}
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;
return; return;
} }
if (!this._isWindowValid(header)) return; const p = Math.min((Date.now() - startTime) / duration, 1);
const { width, height } = windowSize || header.getBounds(); const eased = 1 - Math.pow(1 - p, 3); // ease-out-cubic
header.setBounds({ const x = start.x + (targetX - start.x) * eased;
x: Math.round(currentX), const y = start.y + (targetY - start.y) * eased;
y: Math.round(currentY),
width,
height
});
if (progress < 1) { win.setBounds({ x: Math.round(x), y: Math.round(y), width, height });
this.animationFrameId = setTimeout(animate, 8);
if (p < 1) {
setTimeout(step, 8);
} else { } else {
this.animationFrameId = null; this.layoutManager.updateLayout();
this.isAnimating = false; if (onComplete) {
if (Number.isFinite(targetX) && Number.isFinite(targetY)) { onComplete();
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.updateLayout();
} }
}; };
animate(); step();
} }
moveToEdge(direction) { fade(win, { from, to, duration = 250, onComplete }) {
const header = this.windowPool.get('header'); if (!this._isWindowValid(win)) {
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return; if (onComplete) onComplete();
return;
const display = this.getCurrentDisplay(header); }
const { width, height } = display.workAreaSize; const startOpacity = from ?? win.getOpacity();
const { x: workAreaX, y: workAreaY } = display.workArea; const startTime = Date.now();
const currentBounds = header.getBounds();
const windowSize = { const step = () => {
width: currentBounds.width, if (!this._isWindowValid(win)) {
height: currentBounds.height 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();
}
}; };
step();
let targetX = currentBounds.x; }
let targetY = currentBounds.y;
animateWindowBounds(win, targetBounds, options = {}) {
switch (direction) { if (this.animationTimers.has(win)) {
case 'left': clearTimeout(this.animationTimers.get(win));
targetX = workAreaX;
break;
case 'right':
targetX = workAreaX + width - windowSize.width;
break;
case 'up':
targetY = workAreaY;
break;
case 'down':
targetY = workAreaY + height - windowSize.height;
break;
} }
header.setBounds({ if (!this._isWindowValid(win)) {
x: Math.round(targetX), if (options.onComplete) options.onComplete();
y: Math.round(targetY), return;
width: windowSize.width, }
height: windowSize.height
});
this.headerPosition = { x: targetX, y: targetY }; this.isAnimating = true;
this.updateLayout();
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() { destroy() {

View File

@ -1,9 +1,9 @@
const { screen } = require('electron'); const { screen } = require('electron');
/** /**
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다. *
* @param {BrowserWindow} window - 확인할 객체 * @param {BrowserWindow} window
* @returns {Display} Electron의 Display 객체 * @returns {Display}
*/ */
function getCurrentDisplay(window) { function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay(); if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
@ -27,53 +27,28 @@ class WindowLayoutManager {
this.PADDING = 80; this.PADDING = 80;
} }
/** getHeaderPosition = () => {
* 모든 창의 레이아웃 업데이트를 요청합니다.
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
*/
updateLayout() {
if (this.isUpdating) return;
this.isUpdating = true;
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
/**
* 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
*/
positionWindows() {
const header = this.windowPool.get('header'); 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) { determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY) {
const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height); const headerRelX = headerBounds.x - workAreaX;
const spaceAbove = headerBounds.y; const headerRelY = headerBounds.y - workAreaY;
const spaceLeft = headerBounds.x;
const spaceRight = screenWidth - (headerBounds.x + headerBounds.width); const spaceBelow = screenHeight - (headerRelY + headerBounds.height);
const spaceAbove = headerRelY;
const spaceLeft = headerRelX;
const spaceRight = screenWidth - (headerRelX + headerBounds.width);
if (spaceBelow >= 400) { if (spaceBelow >= 400) {
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' }; 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) { calculateSettingsWindowPosition() {
const ask = this.windowPool.get('ask'); const header = this.windowPool.get('header');
const listen = this.windowPool.get('listen'); const settings = this.windowPool.get('settings');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
if (!askVisible && !listenVisible) return; if (!header || header.isDestroyed() || !settings || settings.isDestroyed()) {
return null;
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 });
} }
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) { calculateHeaderResize(header, { width, height }) {
const headerDisplay = getCurrentDisplay(this.windowPool.get('header')); if (!header) return null;
const settingsDisplay = getCurrentDisplay(settings); const currentBounds = header.getBounds();
if (headerDisplay.id !== settingsDisplay.id) { const centerX = currentBounds.x + currentBounds.width / 2;
settings.__lockedByButton = false; const newX = Math.round(centerX - width / 2);
} else { const display = getCurrentDisplay(header);
return; 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(); if (!headerBounds) return {};
const PAD = 5;
const buttonPadding = 17;
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
let y = headerBounds.y + headerBounds.height + PAD;
const otherVisibleWindows = []; let display;
['listen', 'ask'].forEach(name => { if (headerBoundsOverride) {
const win = this.windowPool.get(name); const boundsCenter = {
if (win && win.isVisible() && !win.isDestroyed()) { x: headerBounds.x + headerBounds.width / 2,
otherVisibleWindows.push({ name, bounds: win.getBounds() }); y: headerBounds.y + headerBounds.height / 2,
} };
}); display = screen.getDisplayNearestPoint(boundsCenter);
} else {
const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height }; display = getCurrentDisplay(header);
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;
}
}
} }
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)); if (askVis) {
y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y)); 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) }); calculateStepMovePosition(header, direction) {
settings.moveTop(); 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} bounds1
* @param {Rectangle} bounds2 * @param {Rectangle} bounds2
* @returns {boolean} 겹침 여부 * @returns {boolean}
*/ */
boundsOverlap(bounds1, bounds2) { boundsOverlap(bounds1, bounds2) {
const margin = 10; const margin = 10;

File diff suppressed because it is too large Load Diff