Compare commits
30 Commits
refactor/0
...
main
Author | SHA1 | Date | |
---|---|---|---|
|
7455907835 | ||
|
60a8c30157 | ||
|
dad74875a0 | ||
|
bba38ac56f | ||
|
ecae4050bb | ||
|
f755fdb9e3 | ||
|
a27ab05fa8 | ||
|
8592d1c4ed | ||
|
ab23c10006 | ||
|
fc16532cd9 | ||
|
7f98acb5e3 | ||
|
698473007a | ||
|
9359b32c01 | ||
|
6ece74737b | ||
|
c0cf74273a | ||
|
4d93df09e2 | ||
|
94ae002d83 | ||
|
a2f57cbfa9 | ||
|
e244ce1d4d | ||
|
f764ad5362 | ||
|
bcc8a59882 | ||
|
c464098951 | ||
|
2a3c7db200 | ||
|
aa14a1d0b6 | ||
|
fbe5c22aa4 | ||
|
a509e87b22 | ||
|
290ee0ed29 | ||
|
2bfcadecb4 | ||
|
8da13dcb27 | ||
|
7d33ea9ca8 |
@ -1,2 +1,2 @@
|
||||
src/assets
|
||||
src/ui/assets
|
||||
node_modules
|
||||
|
2
aec
2
aec
@ -1 +1 @@
|
||||
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f
|
||||
Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163
|
@ -39,7 +39,7 @@ asarUnpack:
|
||||
|
||||
# Windows configuration
|
||||
win:
|
||||
icon: src/assets/logo.ico
|
||||
icon: src/ui/assets/logo.ico
|
||||
target:
|
||||
- target: nsis
|
||||
arch: x64
|
||||
@ -67,7 +67,7 @@ mac:
|
||||
# The application category type
|
||||
category: public.app-category.utilities
|
||||
# Path to the .icns icon file
|
||||
icon: src/assets/logo.icns
|
||||
icon: src/ui/assets/logo.icns
|
||||
# Minimum macOS version (supports both Intel and Apple Silicon)
|
||||
minimumSystemVersion: '11.0'
|
||||
hardenedRuntime: true
|
||||
|
78
package-lock.json
generated
78
package-lock.json
generated
@ -11,6 +11,7 @@
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.56.0",
|
||||
"@deepgram/sdk": "^4.9.1",
|
||||
"@google/genai": "^1.8.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"axios": "^1.10.0",
|
||||
@ -54,6 +55,50 @@
|
||||
"anthropic-ai-sdk": "bin/cli"
|
||||
}
|
||||
},
|
||||
"node_modules/@deepgram/captions": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@deepgram/captions/-/captions-1.2.0.tgz",
|
||||
"integrity": "sha512-8B1C/oTxTxyHlSFubAhNRgCbQ2SQ5wwvtlByn8sDYZvdDtdn/VE2yEPZ4BvUnrKWmsbTQY6/ooLV+9Ka2qmDSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dayjs": "^1.11.10"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deepgram/sdk": {
|
||||
"version": "4.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@deepgram/sdk/-/sdk-4.9.1.tgz",
|
||||
"integrity": "sha512-a30Sed6OIRldnW1U0Q0Orvhjojq4O/1pMv6ijj+3j8735LBBfAJvlJpRCjrgtzBpnkKlY6v3bV5F8qUUSpz2yg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@deepgram/captions": "^1.1.1",
|
||||
"@types/node": "^18.19.39",
|
||||
"cross-fetch": "^3.1.5",
|
||||
"deepmerge": "^4.3.1",
|
||||
"events": "^3.3.0",
|
||||
"ws": "^8.17.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@deepgram/sdk/node_modules/@types/node": {
|
||||
"version": "18.19.118",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.118.tgz",
|
||||
"integrity": "sha512-hIPK0hSrrcaoAu/gJMzN3QClXE4QdCdFvaenJ0JsjIbExP1JFFVH+RHcBt25c9n8bx5dkIfqKE+uw6BmBns7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@deepgram/sdk/node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@develar/schema-utils": {
|
||||
"version": "2.6.5",
|
||||
"dev": true,
|
||||
@ -2992,6 +3037,15 @@
|
||||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/cross-fetch": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cross-fetch/-/cross-fetch-3.2.0.tgz",
|
||||
"integrity": "sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"dev": true,
|
||||
@ -3020,6 +3074,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/dayjs": {
|
||||
"version": "1.11.13",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz",
|
||||
"integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/debounce-fn": {
|
||||
"version": "4.0.0",
|
||||
"license": "MIT",
|
||||
@ -3078,6 +3138,15 @@
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/deepmerge": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/defaults": {
|
||||
"version": "1.0.4",
|
||||
"dev": true,
|
||||
@ -3735,6 +3804,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/events": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.8.x"
|
||||
}
|
||||
},
|
||||
"node_modules/expand-template": {
|
||||
"version": "2.0.3",
|
||||
"license": "(MIT OR WTFPL)",
|
||||
|
@ -33,6 +33,7 @@
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.56.0",
|
||||
"@deepgram/sdk": "^4.9.1",
|
||||
"@google/genai": "^1.8.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"axios": "^1.10.0",
|
||||
|
@ -1,5 +1,5 @@
|
||||
// src/bridge/featureBridge.js
|
||||
const { ipcMain, app } = require('electron');
|
||||
const { ipcMain, app, BrowserWindow } = require('electron');
|
||||
const settingsService = require('../features/settings/settingsService');
|
||||
const authService = require('../features/common/services/authService');
|
||||
const whisperService = require('../features/common/services/whisperService');
|
||||
@ -7,10 +7,11 @@ const ollamaService = require('../features/common/services/ollamaService');
|
||||
const modelStateService = require('../features/common/services/modelStateService');
|
||||
const shortcutsService = require('../features/shortcuts/shortcutsService');
|
||||
const presetRepository = require('../features/common/repositories/preset');
|
||||
|
||||
const localAIManager = require('../features/common/services/localAIManager');
|
||||
const askService = require('../features/ask/askService');
|
||||
const listenService = require('../features/listen/listenService');
|
||||
const permissionService = require('../features/common/services/permissionService');
|
||||
const encryptionService = require('../features/common/services/encryptionService');
|
||||
|
||||
module.exports = {
|
||||
// Renderer로부터의 요청을 수신하고 서비스로 전달
|
||||
@ -20,7 +21,6 @@ module.exports = {
|
||||
ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
|
||||
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => await settingsService.setAutoUpdateSetting(isEnabled));
|
||||
ipcMain.handle('settings:get-model-settings', async () => await settingsService.getModelSettings());
|
||||
ipcMain.handle('settings:validate-and-save-key', async (e, { provider, key }) => await settingsService.validateAndSaveKey(provider, key));
|
||||
ipcMain.handle('settings:clear-api-key', async (e, { provider }) => await settingsService.clearApiKey(provider));
|
||||
ipcMain.handle('settings:set-selected-model', async (e, { type, modelId }) => await settingsService.setSelectedModel(type, modelId));
|
||||
|
||||
@ -29,16 +29,24 @@ module.exports = {
|
||||
ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama());
|
||||
|
||||
// Shortcuts
|
||||
ipcMain.handle('get-current-shortcuts', async () => await shortcutsService.loadKeybinds());
|
||||
ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults());
|
||||
ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
|
||||
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-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
|
||||
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
|
||||
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 };
|
||||
});
|
||||
|
||||
// User/Auth
|
||||
ipcMain.handle('get-current-user', () => authService.getCurrentUser());
|
||||
@ -74,6 +82,7 @@ module.exports = {
|
||||
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));
|
||||
@ -87,6 +96,7 @@ module.exports = {
|
||||
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 {
|
||||
@ -100,14 +110,124 @@ module.exports = {
|
||||
|
||||
// ModelStateService
|
||||
ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
|
||||
ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys());
|
||||
ipcMain.handle('model: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', () => modelStateService.getSelectedModels());
|
||||
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', (e, { type }) => modelStateService.getAvailableModels(type));
|
||||
ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
|
||||
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) => {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('localai:installation-complete', { service });
|
||||
}
|
||||
});
|
||||
});
|
||||
localAIManager.on('error', (error) => {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('localai:error-occurred', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
// Handle error-occurred events from LocalAIManager's error handling
|
||||
localAIManager.on('error-occurred', (error) => {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('localai:error-occurred', error);
|
||||
}
|
||||
});
|
||||
});
|
||||
localAIManager.on('model-ready', (data) => {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('localai:model-ready', data);
|
||||
}
|
||||
});
|
||||
});
|
||||
localAIManager.on('state-changed', (service, state) => {
|
||||
const event = { service, ...state };
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.webContents.send('localai:service-status-changed', event);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 주기적 상태 동기화 시작
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// LocalAI 통합 핸들러 추가
|
||||
ipcMain.handle('localai:install', async (event, { service, options }) => {
|
||||
return await localAIManager.installService(service, options);
|
||||
});
|
||||
ipcMain.handle('localai:get-status', async (event, service) => {
|
||||
return await localAIManager.getServiceStatus(service);
|
||||
});
|
||||
ipcMain.handle('localai:start-service', async (event, service) => {
|
||||
return await localAIManager.startService(service);
|
||||
});
|
||||
ipcMain.handle('localai:stop-service', async (event, service) => {
|
||||
return await localAIManager.stopService(service);
|
||||
});
|
||||
ipcMain.handle('localai:install-model', async (event, { service, modelId, options }) => {
|
||||
return await localAIManager.installModel(service, modelId, options);
|
||||
});
|
||||
ipcMain.handle('localai:get-installed-models', async (event, service) => {
|
||||
return await localAIManager.getInstalledModels(service);
|
||||
});
|
||||
ipcMain.handle('localai:run-diagnostics', async (event, service) => {
|
||||
return await localAIManager.runDiagnostics(service);
|
||||
});
|
||||
ipcMain.handle('localai:repair-service', async (event, service) => {
|
||||
return await localAIManager.repairService(service);
|
||||
});
|
||||
|
||||
// 에러 처리 핸들러
|
||||
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.');
|
||||
},
|
||||
|
@ -1,31 +1,31 @@
|
||||
// src/bridge/windowBridge.js
|
||||
const { ipcMain, BrowserWindow } = require('electron');
|
||||
const windowManager = require('../window/windowManager');
|
||||
const { ipcMain, shell } = require('electron');
|
||||
|
||||
// Bridge는 단순히 IPC 핸들러를 등록하는 역할만 함 (비즈니스 로직 없음)
|
||||
module.exports = {
|
||||
initialize() {
|
||||
// initialize 시점에 windowManager를 require하여 circular dependency 문제 해결
|
||||
const windowManager = require('../window/windowManager');
|
||||
|
||||
// 기존 IPC 핸들러들
|
||||
ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection());
|
||||
ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args));
|
||||
ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus());
|
||||
ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor());
|
||||
ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds));
|
||||
ipcMain.on('show-settings-window', () => windowManager.showSettingsWindow());
|
||||
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
|
||||
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
|
||||
|
||||
ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
|
||||
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
|
||||
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
|
||||
ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));
|
||||
ipcMain.handle('open-external', (event, url) => shell.openExternal(url));
|
||||
|
||||
// Newly moved handlers from windowManager
|
||||
ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state));
|
||||
ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state));
|
||||
ipcMain.handle('get-header-position', () => windowManager.getHeaderPosition());
|
||||
ipcMain.handle('move-header', (event, newX, newY) => windowManager.moveHeader(newX, newY));
|
||||
ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
|
||||
ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight));
|
||||
ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility());
|
||||
ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender));
|
||||
ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow());
|
||||
ipcMain.handle('adjust-window-height', (event, { winName, height }) => windowManager.adjustWindowHeight(winName, height));
|
||||
},
|
||||
|
||||
notifyFocusChange(win, isFocused) {
|
||||
|
@ -2,6 +2,7 @@ const { BrowserWindow } = require('electron');
|
||||
const { createStreamingLLM } = require('../common/ai/factory');
|
||||
// Lazy require helper to avoid circular dependency issues
|
||||
const getWindowManager = () => require('../../window/windowManager');
|
||||
const internalBridge = require('../../bridge/internalBridge');
|
||||
|
||||
const getWindowPool = () => {
|
||||
try {
|
||||
@ -10,8 +11,6 @@ const getWindowPool = () => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
const updateLayout = () => getWindowManager().updateLayout();
|
||||
const ensureAskWindowVisible = () => getWindowManager().ensureAskWindowVisible();
|
||||
|
||||
const sessionRepository = require('../common/repositories/session');
|
||||
const askRepository = require('./repositories');
|
||||
@ -138,8 +137,6 @@ class AskService {
|
||||
console.log('[AskService] Service instance created.');
|
||||
}
|
||||
|
||||
|
||||
|
||||
_broadcastState() {
|
||||
const askWindow = getWindowPool()?.get('ask');
|
||||
if (askWindow && !askWindow.isDestroyed()) {
|
||||
@ -147,29 +144,30 @@ class AskService {
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAskButton() {
|
||||
async toggleAskButton(inputScreenOnly = false) {
|
||||
const askWindow = getWindowPool()?.get('ask');
|
||||
|
||||
// 답변이 있거나 스트리밍 중일 때
|
||||
const hasContent = this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
|
||||
let shouldSendScreenOnly = false;
|
||||
if (inputScreenOnly && this.state.showTextInput && askWindow && askWindow.isVisible()) {
|
||||
shouldSendScreenOnly = true;
|
||||
await this.sendMessage('', []);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasContent = this.state.isLoading || this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
|
||||
|
||||
if (askWindow && askWindow.isVisible() && hasContent) {
|
||||
// 창을 닫는 대신, 텍스트 입력창만 토글합니다.
|
||||
this.state.showTextInput = !this.state.showTextInput;
|
||||
this._broadcastState(); // 변경된 상태 전파
|
||||
this._broadcastState();
|
||||
} else {
|
||||
// 기존의 창 보이기/숨기기 로직
|
||||
if (askWindow && askWindow.isVisible()) {
|
||||
askWindow.webContents.send('window-hide-animation');
|
||||
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;
|
||||
askWindow?.show();
|
||||
updateLayout();
|
||||
askWindow?.webContents.send('window-show-animation');
|
||||
}
|
||||
// 창이 다시 열릴 때를 대비해 상태를 초기화하고 전파합니다.
|
||||
if (this.state.isVisible) {
|
||||
this.state.showTextInput = true;
|
||||
this._broadcastState();
|
||||
@ -177,6 +175,27 @@ class AskService {
|
||||
}
|
||||
}
|
||||
|
||||
async closeAskWindow () {
|
||||
if (this.abortController) {
|
||||
this.abortController.abort('Window closed by user');
|
||||
this.abortController = null;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
isVisible : false,
|
||||
isLoading : false,
|
||||
isStreaming : false,
|
||||
currentQuestion: '',
|
||||
currentResponse: '',
|
||||
showTextInput : true,
|
||||
};
|
||||
this._broadcastState();
|
||||
|
||||
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
*
|
||||
@ -197,7 +216,16 @@ class AskService {
|
||||
* @returns {Promise<{success: boolean, response?: string, error?: string}>}
|
||||
*/
|
||||
async sendMessage(userPrompt, conversationHistoryRaw=[]) {
|
||||
ensureAskWindowVisible();
|
||||
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.');
|
||||
@ -205,31 +233,17 @@ class AskService {
|
||||
this.abortController = new AbortController();
|
||||
const { signal } = this.abortController;
|
||||
|
||||
if (!userPrompt || userPrompt.trim().length === 0) {
|
||||
console.warn('[AskService] Cannot process empty message');
|
||||
return { success: false, error: 'Empty message' };
|
||||
}
|
||||
|
||||
let sessionId;
|
||||
|
||||
try {
|
||||
console.log(`[AskService] Processing message: ${userPrompt.substring(0, 50)}...`);
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
isLoading: true,
|
||||
isStreaming: false,
|
||||
currentQuestion: userPrompt,
|
||||
currentResponse: '',
|
||||
showTextInput: false,
|
||||
};
|
||||
this._broadcastState();
|
||||
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 = modelStateService.getCurrentModelInfo('llm');
|
||||
const modelInfo = await modelStateService.getCurrentModelInfo('llm');
|
||||
if (!modelInfo || !modelInfo.apiKey) {
|
||||
throw new Error('AI model or API key not configured.');
|
||||
}
|
||||
@ -239,9 +253,9 @@ class AskService {
|
||||
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 },
|
||||
{
|
||||
@ -383,7 +397,6 @@ class AskService {
|
||||
this._broadcastState();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AskService] Failed to parse stream data:', { line: data, error: error.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -429,6 +442,7 @@ class AskService {
|
||||
errorMessage.includes('not supported')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const askService = new AskService();
|
||||
|
@ -57,6 +57,14 @@ const PROVIDERS = {
|
||||
],
|
||||
sttModels: [],
|
||||
},
|
||||
'deepgram': {
|
||||
name: 'Deepgram',
|
||||
handler: () => require("./providers/deepgram"),
|
||||
llmModels: [],
|
||||
sttModels: [
|
||||
{ id: 'nova-3', name: 'Nova-3 (General)' },
|
||||
],
|
||||
},
|
||||
'ollama': {
|
||||
name: 'Ollama (Local)',
|
||||
handler: () => require("./providers/ollama"),
|
||||
@ -148,6 +156,7 @@ function getProviderClass(providerId) {
|
||||
'openai': 'OpenAIProvider',
|
||||
'anthropic': 'AnthropicProvider',
|
||||
'gemini': 'GeminiProvider',
|
||||
'deepgram': 'DeepgramProvider',
|
||||
'ollama': 'OllamaProvider',
|
||||
'whisper': 'WhisperProvider'
|
||||
};
|
||||
|
111
src/features/common/ai/providers/deepgram.js
Normal file
111
src/features/common/ai/providers/deepgram.js
Normal 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 (10 s)'));
|
||||
}, 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
|
||||
};
|
@ -41,7 +41,7 @@ class WhisperSTTSession extends EventEmitter {
|
||||
|
||||
startProcessingLoop() {
|
||||
this.processingInterval = setInterval(async () => {
|
||||
const minBufferSize = 24000 * 2 * 0.15;
|
||||
const minBufferSize = 16000 * 2 * 0.15;
|
||||
if (this.audioBuffer.length >= minBufferSize && !this.process) {
|
||||
console.log(`[WhisperSTT-${this.sessionId}] Processing audio chunk, buffer size: ${this.audioBuffer.length}`);
|
||||
await this.processAudioChunk();
|
||||
|
@ -2,41 +2,49 @@ const DOWNLOAD_CHECKSUMS = {
|
||||
ollama: {
|
||||
dmg: {
|
||||
url: 'https://ollama.com/download/Ollama.dmg',
|
||||
sha256: null // To be updated with actual checksum
|
||||
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
|
||||
},
|
||||
exe: {
|
||||
url: 'https://ollama.com/download/OllamaSetup.exe',
|
||||
sha256: null // To be updated with actual checksum
|
||||
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
|
||||
},
|
||||
linux: {
|
||||
url: 'curl -fsSL https://ollama.com/install.sh | sh',
|
||||
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
|
||||
}
|
||||
},
|
||||
whisper: {
|
||||
models: {
|
||||
'whisper-tiny': {
|
||||
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
|
||||
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-tiny.bin',
|
||||
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
|
||||
},
|
||||
'whisper-base': {
|
||||
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
|
||||
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-base.bin',
|
||||
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
|
||||
},
|
||||
'whisper-small': {
|
||||
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
|
||||
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-small.bin',
|
||||
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
|
||||
},
|
||||
'whisper-medium': {
|
||||
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-medium.bin',
|
||||
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208'
|
||||
}
|
||||
},
|
||||
binaries: {
|
||||
'v1.7.6': {
|
||||
mac: {
|
||||
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-mac-x64.zip',
|
||||
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
|
||||
},
|
||||
windows: {
|
||||
url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
|
||||
sha256: null // To be updated with actual checksum
|
||||
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
|
||||
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
|
||||
},
|
||||
linux: {
|
||||
url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
|
||||
sha256: null // To be updated with actual checksum
|
||||
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
|
||||
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -91,25 +91,16 @@ const LATEST_SCHEMA = {
|
||||
},
|
||||
provider_settings: {
|
||||
columns: [
|
||||
{ name: 'uid', type: 'TEXT NOT NULL' },
|
||||
{ name: 'provider', type: 'TEXT NOT NULL' },
|
||||
{ name: 'api_key', type: 'TEXT' },
|
||||
{ name: 'selected_llm_model', type: 'TEXT' },
|
||||
{ name: 'selected_stt_model', type: 'TEXT' },
|
||||
{ name: 'is_active_llm', type: 'INTEGER DEFAULT 0' },
|
||||
{ name: 'is_active_stt', type: 'INTEGER DEFAULT 0' },
|
||||
{ name: 'created_at', type: 'INTEGER' },
|
||||
{ name: 'updated_at', type: 'INTEGER' }
|
||||
],
|
||||
constraints: ['PRIMARY KEY (uid, provider)']
|
||||
},
|
||||
user_model_selections: {
|
||||
columns: [
|
||||
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
|
||||
{ name: 'selected_llm_provider', type: 'TEXT' },
|
||||
{ name: 'selected_llm_model', type: 'TEXT' },
|
||||
{ name: 'selected_stt_provider', type: 'TEXT' },
|
||||
{ name: 'selected_stt_model', type: 'TEXT' },
|
||||
{ name: 'updated_at', type: 'INTEGER' }
|
||||
]
|
||||
constraints: ['PRIMARY KEY (provider)']
|
||||
},
|
||||
shortcuts: {
|
||||
columns: [
|
||||
@ -117,6 +108,12 @@ const LATEST_SCHEMA = {
|
||||
{ name: 'accelerator', type: 'TEXT NOT NULL' },
|
||||
{ name: 'created_at', type: 'INTEGER' }
|
||||
]
|
||||
},
|
||||
permissions: {
|
||||
columns: [
|
||||
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
|
||||
{ name: 'keychain_completed', type: 'INTEGER DEFAULT 0' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -33,7 +33,13 @@ function createEncryptedConverter(fieldsToEncrypt = []) {
|
||||
|
||||
for (const field of fieldsToEncrypt) {
|
||||
if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) {
|
||||
appObject[field] = encryptionService.decrypt(appObject[field]);
|
||||
try {
|
||||
appObject[field] = encryptionService.decrypt(appObject[field]);
|
||||
} catch (error) {
|
||||
console.warn(`[FirestoreConverter] Failed to decrypt field '${field}' (possibly plaintext or key mismatch):`, error.message);
|
||||
// Keep the original value instead of failing
|
||||
// appObject[field] remains as is
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -6,6 +6,6 @@ function getRepository() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args),
|
||||
checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...args),
|
||||
markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args),
|
||||
checkKeychainCompleted: (...args) => getRepository().checkKeychainCompleted(...args),
|
||||
};
|
@ -1,14 +1,18 @@
|
||||
const sqliteClient = require('../../services/sqliteClient');
|
||||
|
||||
async function markPermissionsAsCompleted() {
|
||||
return sqliteClient.markPermissionsAsCompleted();
|
||||
function markKeychainCompleted(uid) {
|
||||
return sqliteClient.query(
|
||||
'INSERT OR REPLACE INTO permissions (uid, keychain_completed) VALUES (?, 1)',
|
||||
[uid]
|
||||
);
|
||||
}
|
||||
|
||||
async function checkPermissionsCompleted() {
|
||||
return sqliteClient.checkPermissionsCompleted();
|
||||
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 = {
|
||||
markPermissionsAsCompleted,
|
||||
checkPermissionsCompleted,
|
||||
markKeychainCompleted,
|
||||
checkKeychainCompleted
|
||||
};
|
@ -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
|
||||
};
|
@ -1,65 +1,68 @@
|
||||
const firebaseRepository = require('./firebase.repository');
|
||||
const sqliteRepository = require('./sqlite.repository');
|
||||
|
||||
let authService = null;
|
||||
|
||||
function setAuthService(service) {
|
||||
authService = service;
|
||||
}
|
||||
|
||||
function getBaseRepository() {
|
||||
if (!authService) {
|
||||
throw new Error('AuthService not set for providerSettings repository');
|
||||
}
|
||||
|
||||
const user = authService.getCurrentUser();
|
||||
return user.isLoggedIn ? firebaseRepository : sqliteRepository;
|
||||
// For now, we only have sqlite. This could be expanded later.
|
||||
return sqliteRepository;
|
||||
}
|
||||
|
||||
const providerSettingsRepositoryAdapter = {
|
||||
// Core CRUD operations
|
||||
async getByProvider(provider) {
|
||||
const repo = getBaseRepository();
|
||||
const uid = authService.getCurrentUserId();
|
||||
return await repo.getByProvider(uid, provider);
|
||||
return await repo.getByProvider(provider);
|
||||
},
|
||||
|
||||
async getAllByUid() {
|
||||
async getAll() {
|
||||
const repo = getBaseRepository();
|
||||
const uid = authService.getCurrentUserId();
|
||||
return await repo.getAllByUid(uid);
|
||||
return await repo.getAll();
|
||||
},
|
||||
|
||||
async upsert(provider, settings) {
|
||||
const repo = getBaseRepository();
|
||||
const uid = authService.getCurrentUserId();
|
||||
const now = Date.now();
|
||||
|
||||
const settingsWithMeta = {
|
||||
...settings,
|
||||
uid,
|
||||
provider,
|
||||
updated_at: now,
|
||||
created_at: settings.created_at || now
|
||||
};
|
||||
|
||||
return await repo.upsert(uid, provider, settingsWithMeta);
|
||||
return await repo.upsert(provider, settingsWithMeta);
|
||||
},
|
||||
|
||||
async remove(provider) {
|
||||
const repo = getBaseRepository();
|
||||
const uid = authService.getCurrentUserId();
|
||||
return await repo.remove(uid, provider);
|
||||
return await repo.remove(provider);
|
||||
},
|
||||
|
||||
async removeAllByUid() {
|
||||
async removeAll() {
|
||||
const repo = getBaseRepository();
|
||||
const uid = authService.getCurrentUserId();
|
||||
return await repo.removeAllByUid(uid);
|
||||
return await repo.removeAll();
|
||||
},
|
||||
|
||||
async getRawApiKeys() {
|
||||
// This function should always target the local sqlite DB,
|
||||
// as it's part of the local-first boot sequence.
|
||||
return await sqliteRepository.getRawApiKeys();
|
||||
},
|
||||
|
||||
async getActiveProvider(type) {
|
||||
const repo = getBaseRepository();
|
||||
return await repo.getActiveProvider(type);
|
||||
},
|
||||
|
||||
async setActiveProvider(provider, type) {
|
||||
const repo = getBaseRepository();
|
||||
return await repo.setActiveProvider(provider, type);
|
||||
},
|
||||
|
||||
async getActiveSettings() {
|
||||
const repo = getBaseRepository();
|
||||
return await repo.getActiveSettings();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
...providerSettingsRepositoryAdapter,
|
||||
setAuthService
|
||||
...providerSettingsRepositoryAdapter
|
||||
};
|
@ -1,37 +1,59 @@
|
||||
const sqliteClient = require('../../services/sqliteClient');
|
||||
const encryptionService = require('../../services/encryptionService');
|
||||
|
||||
function getByProvider(uid, provider) {
|
||||
function getByProvider(provider) {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
|
||||
return stmt.get(uid, provider) || null;
|
||||
const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?');
|
||||
const result = stmt.get(provider) || null;
|
||||
|
||||
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
|
||||
result.api_key = encryptionService.decrypt(result.api_key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getAllByUid(uid) {
|
||||
function getAll() {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
|
||||
return stmt.all(uid);
|
||||
const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider');
|
||||
const results = stmt.all();
|
||||
|
||||
return results.map(result => {
|
||||
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
|
||||
result.api_key = encryptionService.decrypt(result.api_key);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function upsert(uid, provider, settings) {
|
||||
function upsert(provider, settings) {
|
||||
// Validate: prevent direct setting of active status
|
||||
if (settings.is_active_llm || settings.is_active_stt) {
|
||||
console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.');
|
||||
}
|
||||
|
||||
const db = sqliteClient.getDb();
|
||||
|
||||
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(uid, provider) DO UPDATE SET
|
||||
INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(provider) DO UPDATE SET
|
||||
api_key = excluded.api_key,
|
||||
selected_llm_model = excluded.selected_llm_model,
|
||||
selected_stt_model = excluded.selected_stt_model,
|
||||
-- is_active_llm and is_active_stt are NOT updated here
|
||||
-- Use setActiveProvider() to change active status
|
||||
updated_at = excluded.updated_at
|
||||
`);
|
||||
|
||||
const result = stmt.run(
|
||||
uid,
|
||||
provider,
|
||||
settings.api_key || null,
|
||||
settings.selected_llm_model || null,
|
||||
settings.selected_stt_model || null,
|
||||
0, // is_active_llm - always 0, use setActiveProvider to activate
|
||||
0, // is_active_stt - always 0, use setActiveProvider to activate
|
||||
settings.created_at || Date.now(),
|
||||
settings.updated_at
|
||||
);
|
||||
@ -39,24 +61,100 @@ function upsert(uid, provider, settings) {
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function remove(uid, provider) {
|
||||
function remove(provider) {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?');
|
||||
const result = stmt.run(uid, provider);
|
||||
const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?');
|
||||
const result = stmt.run(provider);
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function removeAllByUid(uid) {
|
||||
function removeAll() {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?');
|
||||
const result = stmt.run(uid);
|
||||
const stmt = db.prepare('DELETE FROM provider_settings');
|
||||
const result = stmt.run();
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function getRawApiKeys() {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('SELECT api_key FROM provider_settings');
|
||||
return stmt.all();
|
||||
}
|
||||
|
||||
// Get active provider for a specific type (llm or stt)
|
||||
function getActiveProvider(type) {
|
||||
const db = sqliteClient.getDb();
|
||||
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
|
||||
const stmt = db.prepare(`SELECT * FROM provider_settings WHERE ${column} = 1`);
|
||||
const result = stmt.get() || null;
|
||||
|
||||
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
|
||||
result.api_key = encryptionService.decrypt(result.api_key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Set active provider for a specific type
|
||||
function setActiveProvider(provider, type) {
|
||||
const db = sqliteClient.getDb();
|
||||
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
|
||||
|
||||
// Start transaction to ensure only one provider is active
|
||||
db.transaction(() => {
|
||||
// First, deactivate all providers for this type
|
||||
const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0`);
|
||||
deactivateStmt.run();
|
||||
|
||||
// Then activate the specified provider
|
||||
if (provider) {
|
||||
const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE provider = ?`);
|
||||
activateStmt.run(provider);
|
||||
}
|
||||
})();
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Get all active settings (both llm and stt)
|
||||
function getActiveSettings() {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare(`
|
||||
SELECT * FROM provider_settings
|
||||
WHERE (is_active_llm = 1 OR is_active_stt = 1)
|
||||
ORDER BY provider
|
||||
`);
|
||||
const results = stmt.all();
|
||||
|
||||
// Decrypt API keys and organize by type
|
||||
const activeSettings = {
|
||||
llm: null,
|
||||
stt: null
|
||||
};
|
||||
|
||||
results.forEach(result => {
|
||||
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
|
||||
result.api_key = encryptionService.decrypt(result.api_key);
|
||||
}
|
||||
if (result.is_active_llm) {
|
||||
activeSettings.llm = result;
|
||||
}
|
||||
if (result.is_active_stt) {
|
||||
activeSettings.stt = result;
|
||||
}
|
||||
});
|
||||
|
||||
return activeSettings;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getByProvider,
|
||||
getAllByUid,
|
||||
getAll,
|
||||
upsert,
|
||||
remove,
|
||||
removeAllByUid
|
||||
removeAll,
|
||||
getRawApiKeys,
|
||||
getActiveProvider,
|
||||
setActiveProvider,
|
||||
getActiveSettings
|
||||
};
|
@ -3,15 +3,19 @@ const firebaseRepository = require('./firebase.repository');
|
||||
|
||||
let authService = null;
|
||||
|
||||
function setAuthService(service) {
|
||||
authService = service;
|
||||
function getAuthService() {
|
||||
if (!authService) {
|
||||
authService = require('../../services/authService');
|
||||
}
|
||||
return authService;
|
||||
}
|
||||
|
||||
function getBaseRepository() {
|
||||
if (!authService) {
|
||||
throw new Error('AuthService has not been set for the user repository.');
|
||||
const service = getAuthService();
|
||||
if (!service) {
|
||||
throw new Error('AuthService could not be loaded for the user repository.');
|
||||
}
|
||||
const user = authService.getCurrentUser();
|
||||
const user = service.getCurrentUser();
|
||||
if (user && user.isLoggedIn) {
|
||||
return firebaseRepository;
|
||||
}
|
||||
@ -25,24 +29,23 @@ const userRepositoryAdapter = {
|
||||
},
|
||||
|
||||
getById: () => {
|
||||
const uid = authService.getCurrentUserId();
|
||||
const uid = getAuthService().getCurrentUserId();
|
||||
return getBaseRepository().getById(uid);
|
||||
},
|
||||
|
||||
|
||||
|
||||
update: (updateData) => {
|
||||
const uid = authService.getCurrentUserId();
|
||||
const uid = getAuthService().getCurrentUserId();
|
||||
return getBaseRepository().update({ uid, ...updateData });
|
||||
},
|
||||
|
||||
deleteById: () => {
|
||||
const uid = authService.getCurrentUserId();
|
||||
const uid = getAuthService().getCurrentUserId();
|
||||
return getBaseRepository().deleteById(uid);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
...userRepositoryAdapter,
|
||||
setAuthService
|
||||
...userRepositoryAdapter
|
||||
};
|
@ -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
|
||||
};
|
@ -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
|
||||
};
|
@ -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
|
||||
};
|
@ -6,7 +6,7 @@ const encryptionService = require('./encryptionService');
|
||||
const migrationService = require('./migrationService');
|
||||
const sessionRepository = require('../repositories/session');
|
||||
const providerSettingsRepository = require('../repositories/providerSettings');
|
||||
const userModelSelectionsRepository = require('../repositories/userModelSelections');
|
||||
const permissionService = require('./permissionService');
|
||||
|
||||
async function getVirtualKeyByEmail(email, idToken) {
|
||||
if (!idToken) {
|
||||
@ -43,23 +43,14 @@ class AuthService {
|
||||
this.isInitialized = false;
|
||||
|
||||
// This ensures the key is ready before any login/logout state change.
|
||||
encryptionService.initializeKey(this.currentUserId);
|
||||
this.initializationPromise = null;
|
||||
|
||||
sessionRepository.setAuthService(this);
|
||||
providerSettingsRepository.setAuthService(this);
|
||||
userModelSelectionsRepository.setAuthService(this);
|
||||
}
|
||||
|
||||
initialize() {
|
||||
if (this.isInitialized) return this.initializationPromise;
|
||||
|
||||
// --- Break the circular dependency ---
|
||||
// Inject this authService instance into the session repository so it can be used
|
||||
// without a direct `require` cycle.
|
||||
sessionRepository.setAuthService(this);
|
||||
// --- End of dependency injection ---
|
||||
|
||||
this.initializationPromise = new Promise((resolve) => {
|
||||
const auth = getFirebaseAuth();
|
||||
onAuthStateChanged(auth, async (user) => {
|
||||
@ -75,29 +66,32 @@ class AuthService {
|
||||
// Clean up any zombie sessions from a previous run for this user.
|
||||
await sessionRepository.endAllActiveSessions();
|
||||
|
||||
// ** Initialize encryption key for the logged-in user **
|
||||
await encryptionService.initializeKey(user.uid);
|
||||
// ** Initialize encryption key for the logged-in user if permissions are already granted **
|
||||
if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) {
|
||||
console.warn('[AuthService] Keychain permission not yet completed for this user. Deferring key initialization.');
|
||||
} else {
|
||||
await encryptionService.initializeKey(user.uid);
|
||||
}
|
||||
|
||||
// ** Check for and run data migration for the user **
|
||||
// No 'await' here, so it runs in the background without blocking startup.
|
||||
migrationService.checkAndRunMigration(user);
|
||||
|
||||
// ***** CRITICAL: Wait for the virtual key and model state update to complete *****
|
||||
try {
|
||||
const idToken = await user.getIdToken(true);
|
||||
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
|
||||
|
||||
// Start background task to fetch and save virtual key
|
||||
(async () => {
|
||||
try {
|
||||
const idToken = await user.getIdToken(true);
|
||||
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
|
||||
|
||||
if (global.modelStateService) {
|
||||
global.modelStateService.setFirebaseVirtualKey(virtualKey);
|
||||
}
|
||||
console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
|
||||
if (global.modelStateService) {
|
||||
// The model state service now writes directly to the DB, no in-memory state.
|
||||
await global.modelStateService.setFirebaseVirtualKey(virtualKey);
|
||||
}
|
||||
})();
|
||||
console.log(`[AuthService] Virtual key for ${user.email} has been processed and state updated.`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AuthService] Failed to fetch or save virtual key:', error);
|
||||
// This is not critical enough to halt the login, but we should log it.
|
||||
}
|
||||
|
||||
} else {
|
||||
// User signed OUT
|
||||
@ -105,7 +99,8 @@ class AuthService {
|
||||
if (previousUser) {
|
||||
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
|
||||
if (global.modelStateService) {
|
||||
global.modelStateService.setFirebaseVirtualKey(null);
|
||||
// The model state service now writes directly to the DB.
|
||||
await global.modelStateService.setFirebaseVirtualKey(null);
|
||||
}
|
||||
}
|
||||
this.currentUser = null;
|
||||
@ -115,8 +110,7 @@ class AuthService {
|
||||
// End active sessions for the local/default user as well.
|
||||
await sessionRepository.endAllActiveSessions();
|
||||
|
||||
// ** Initialize encryption key for the default/local user **
|
||||
await encryptionService.initializeKey(this.currentUserId);
|
||||
encryptionService.resetSessionKey();
|
||||
}
|
||||
this.broadcastUserState();
|
||||
|
||||
@ -181,7 +175,6 @@ class AuthService {
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
getCurrentUserId() {
|
||||
return this.currentUserId;
|
||||
}
|
||||
|
@ -10,10 +10,13 @@ class DatabaseInitializer {
|
||||
|
||||
// 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
|
||||
const userDataPath = app.getPath('userData');
|
||||
// In both development and production mode, the database is stored in the userData directory:
|
||||
// macOS: ~/Library/Application Support/Glass/pickleglass.db
|
||||
// Windows: %APPDATA%\Glass\pickleglass.db
|
||||
this.dbPath = path.join(userDataPath, 'pickleglass.db');
|
||||
this.dataDir = userDataPath;
|
||||
|
||||
// 원본 DB 경로 (패키지 내 읽기 전용 위치)
|
||||
// The original DB path (read-only location in the package)
|
||||
this.sourceDbPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'data', 'pickleglass.db')
|
||||
: path.join(app.getAppPath(), 'data', 'pickleglass.db');
|
||||
@ -52,7 +55,7 @@ class DatabaseInitializer {
|
||||
try {
|
||||
this.ensureDatabaseExists();
|
||||
|
||||
await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
|
||||
sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
|
||||
|
||||
// This single call will now synchronize the schema and then init default data.
|
||||
await sqliteClient.initTables();
|
||||
|
@ -9,6 +9,8 @@ try {
|
||||
keytar = null;
|
||||
}
|
||||
|
||||
const permissionService = require('./permissionService');
|
||||
|
||||
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
|
||||
let sessionKey = null; // In-memory fallback key
|
||||
|
||||
@ -31,6 +33,8 @@ async function initializeKey(userId) {
|
||||
throw new Error('A user ID must be provided to initialize the encryption key.');
|
||||
}
|
||||
|
||||
let keyRetrieved = false;
|
||||
|
||||
if (keytar) {
|
||||
try {
|
||||
let key = await keytar.getPassword(SERVICE_NAME, userId);
|
||||
@ -41,6 +45,7 @@ async function initializeKey(userId) {
|
||||
console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
|
||||
} else {
|
||||
console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
|
||||
keyRetrieved = true;
|
||||
}
|
||||
sessionKey = key;
|
||||
} catch (error) {
|
||||
@ -56,11 +61,25 @@ async function initializeKey(userId) {
|
||||
}
|
||||
}
|
||||
|
||||
// Mark keychain completed in permissions DB if this is the first successful retrieval or storage
|
||||
try {
|
||||
await permissionService.markKeychainCompleted(userId);
|
||||
if (keyRetrieved) {
|
||||
console.log(`[EncryptionService] Keychain completion marked in DB for ${userId}.`);
|
||||
}
|
||||
} catch (permErr) {
|
||||
console.error('[EncryptionService] Failed to mark keychain completion:', permErr);
|
||||
}
|
||||
|
||||
if (!sessionKey) {
|
||||
throw new Error('Failed to initialize encryption key.');
|
||||
}
|
||||
}
|
||||
|
||||
function resetSessionKey() {
|
||||
sessionKey = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a given text using AES-256-GCM.
|
||||
* @param {string} text The text to encrypt.
|
||||
@ -129,12 +148,28 @@ function decrypt(encryptedText) {
|
||||
} catch (error) {
|
||||
// It's common for this to fail if the data is not encrypted (e.g., legacy data).
|
||||
// In that case, we return the original value.
|
||||
console.error('[EncryptionService] Decryption failed:', error);
|
||||
return encryptedText;
|
||||
}
|
||||
}
|
||||
|
||||
function looksEncrypted(str) {
|
||||
if (!str || typeof str !== 'string') return false;
|
||||
// Base64 chars + optional '=' padding
|
||||
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(str)) return false;
|
||||
try {
|
||||
const buf = Buffer.from(str, 'base64');
|
||||
// Our AES-GCM cipher text must be at least 32 bytes (IV 16 + TAG 16)
|
||||
return buf.length >= 32;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeKey,
|
||||
resetSessionKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
looksEncrypted,
|
||||
};
|
639
src/features/common/services/localAIManager.js
Normal file
639
src/features/common/services/localAIManager.js
Normal 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;
|
@ -1,308 +0,0 @@
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { EventEmitter } = require('events');
|
||||
const { BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const https = require('https');
|
||||
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();
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPlatform() {
|
||||
return process.platform;
|
||||
}
|
||||
|
||||
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);
|
||||
// 각 서비스에서 직접 브로드캐스트하도록 변경
|
||||
}
|
||||
|
||||
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
|
||||
modelId = null // 모델 ID를 위한 추가 옵션
|
||||
} = 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 (totalSize > 0) {
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
|
||||
// 이벤트 기반 진행률 보고는 각 서비스에서 직접 처리
|
||||
|
||||
// 기존 콜백 지원 (호환성 유지)
|
||||
if (onProgress) {
|
||||
onProgress(progress, downloadedSize, totalSize);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
// download-complete 이벤트는 각 서비스에서 직접 처리
|
||||
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, // 모델 ID를 위한 추가 옵션
|
||||
...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) {
|
||||
// download-error 이벤트는 각 서비스에서 직접 처리
|
||||
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;
|
@ -1,638 +1,437 @@
|
||||
const Store = require('electron-store');
|
||||
const fetch = require('node-fetch');
|
||||
const { EventEmitter } = require('events');
|
||||
const { BrowserWindow } = require('electron');
|
||||
const Store = require('electron-store');
|
||||
const { PROVIDERS, getProviderClass } = require('../ai/factory');
|
||||
const encryptionService = require('./encryptionService');
|
||||
const providerSettingsRepository = require('../repositories/providerSettings');
|
||||
const userModelSelectionsRepository = require('../repositories/userModelSelections');
|
||||
|
||||
// Import authService directly (singleton)
|
||||
const authService = require('./authService');
|
||||
const ollamaModelRepository = require('../repositories/ollamaModel');
|
||||
|
||||
class ModelStateService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.authService = authService;
|
||||
// electron-store는 오직 레거시 데이터 마이그레이션 용도로만 사용됩니다.
|
||||
this.store = new Store({ name: 'pickle-glass-model-state' });
|
||||
this.state = {};
|
||||
this.hasMigrated = false;
|
||||
|
||||
// Set auth service for repositories
|
||||
providerSettingsRepository.setAuthService(authService);
|
||||
userModelSelectionsRepository.setAuthService(authService);
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('[ModelStateService] Initializing...');
|
||||
await this._loadStateForCurrentUser();
|
||||
console.log('[ModelStateService] Initialization complete');
|
||||
console.log('[ModelStateService] Initializing one-time setup...');
|
||||
await this._initializeEncryption();
|
||||
await this._runMigrations();
|
||||
this.setupLocalAIStateSync();
|
||||
await this._autoSelectAvailableModels([], true);
|
||||
console.log('[ModelStateService] One-time setup complete.');
|
||||
}
|
||||
|
||||
_logCurrentSelection() {
|
||||
const llmModel = this.state.selectedModels.llm;
|
||||
const sttModel = this.state.selectedModels.stt;
|
||||
const llmProvider = this.getProviderForModel('llm', llmModel) || 'None';
|
||||
const sttProvider = this.getProviderForModel('stt', sttModel) || 'None';
|
||||
|
||||
console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
|
||||
async _initializeEncryption() {
|
||||
try {
|
||||
const rows = await providerSettingsRepository.getRawApiKeys();
|
||||
if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) {
|
||||
console.log('[ModelStateService] Encrypted keys detected, initializing encryption...');
|
||||
const userIdForMigration = this.authService.getCurrentUserId();
|
||||
await encryptionService.initializeKey(userIdForMigration);
|
||||
} else {
|
||||
console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
_autoSelectAvailableModels(forceReselectionForTypes = []) {
|
||||
console.log(`[ModelStateService] Running auto-selection for models. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
setupLocalAIStateSync() {
|
||||
const localAIManager = require('./localAIManager');
|
||||
localAIManager.on('state-changed', (service, status) => {
|
||||
this.handleLocalAIStateChange(service, status);
|
||||
});
|
||||
}
|
||||
|
||||
async handleLocalAIStateChange(service, state) {
|
||||
console.log(`[ModelStateService] LocalAI state changed: ${service}`, state);
|
||||
if (!state.installed || !state.running) {
|
||||
const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : [];
|
||||
await this._autoSelectAvailableModels(types);
|
||||
}
|
||||
this.emit('state-updated', await this.getLiveState());
|
||||
}
|
||||
|
||||
async getLiveState() {
|
||||
const providerSettings = await providerSettingsRepository.getAll();
|
||||
const apiKeys = {};
|
||||
Object.keys(PROVIDERS).forEach(provider => {
|
||||
const setting = providerSettings.find(s => s.provider === provider);
|
||||
apiKeys[provider] = setting?.api_key || null;
|
||||
});
|
||||
|
||||
const activeSettings = await providerSettingsRepository.getActiveSettings();
|
||||
const selectedModels = {
|
||||
llm: activeSettings.llm?.selected_llm_model || null,
|
||||
stt: activeSettings.stt?.selected_stt_model || null
|
||||
};
|
||||
|
||||
return { apiKeys, selectedModels };
|
||||
}
|
||||
|
||||
async _autoSelectAvailableModels(forceReselectionForTypes = [], isInitialBoot = false) {
|
||||
console.log(`[ModelStateService] Running auto-selection. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
|
||||
const { apiKeys, selectedModels } = await this.getLiveState();
|
||||
const types = ['llm', 'stt'];
|
||||
|
||||
types.forEach(type => {
|
||||
const currentModelId = this.state.selectedModels[type];
|
||||
for (const type of types) {
|
||||
const currentModelId = selectedModels[type];
|
||||
let isCurrentModelValid = false;
|
||||
|
||||
const forceReselection = forceReselectionForTypes.includes(type);
|
||||
|
||||
if (currentModelId && !forceReselection) {
|
||||
const provider = this.getProviderForModel(type, currentModelId);
|
||||
const apiKey = this.getApiKey(provider);
|
||||
// For Ollama, 'local' is a valid API key
|
||||
if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) {
|
||||
const provider = this.getProviderForModel(currentModelId, type);
|
||||
const apiKey = apiKeys[provider];
|
||||
if (provider && apiKey) {
|
||||
isCurrentModelValid = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isCurrentModelValid) {
|
||||
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or re-selection forced. Finding an alternative...`);
|
||||
const availableModels = this.getAvailableModels(type);
|
||||
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`);
|
||||
const availableModels = await this.getAvailableModels(type);
|
||||
if (availableModels.length > 0) {
|
||||
// Prefer API providers over local providers for auto-selection
|
||||
const apiModel = availableModels.find(model => {
|
||||
const provider = this.getProviderForModel(type, model.id);
|
||||
const provider = this.getProviderForModel(model.id, type);
|
||||
return provider && provider !== 'ollama' && provider !== 'whisper';
|
||||
});
|
||||
|
||||
const selectedModel = apiModel || availableModels[0];
|
||||
this.state.selectedModels[type] = selectedModel.id;
|
||||
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${selectedModel.id} (preferred: ${apiModel ? 'API' : 'local'})`);
|
||||
const newModel = apiModel || availableModels[0];
|
||||
await this.setSelectedModel(type, newModel.id);
|
||||
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`);
|
||||
} else {
|
||||
this.state.selectedModels[type] = null;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async _migrateFromElectronStore() {
|
||||
console.log('[ModelStateService] Starting migration from electron-store to database...');
|
||||
const userId = this.authService.getCurrentUserId();
|
||||
|
||||
try {
|
||||
// Get data from electron-store
|
||||
const legacyData = this.store.get(`users.${userId}`, null);
|
||||
|
||||
if (!legacyData) {
|
||||
console.log('[ModelStateService] No legacy data to migrate');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[ModelStateService] Found legacy data, migrating...');
|
||||
|
||||
// Migrate provider settings (API keys and selected models per provider)
|
||||
const { apiKeys = {}, selectedModels = {} } = legacyData;
|
||||
|
||||
for (const [provider, apiKey] of Object.entries(apiKeys)) {
|
||||
if (apiKey && PROVIDERS[provider]) {
|
||||
// For encrypted keys, they are already decrypted in _loadStateForCurrentUser
|
||||
await providerSettingsRepository.upsert(provider, {
|
||||
api_key: apiKey
|
||||
});
|
||||
console.log(`[ModelStateService] Migrated API key for ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate global model selections
|
||||
if (selectedModels.llm || selectedModels.stt) {
|
||||
const llmProvider = selectedModels.llm ? this.getProviderForModel('llm', selectedModels.llm) : null;
|
||||
const sttProvider = selectedModels.stt ? this.getProviderForModel('stt', selectedModels.stt) : null;
|
||||
|
||||
await userModelSelectionsRepository.upsert({
|
||||
selected_llm_provider: llmProvider,
|
||||
selected_llm_model: selectedModels.llm,
|
||||
selected_stt_provider: sttProvider,
|
||||
selected_stt_model: selectedModels.stt
|
||||
});
|
||||
console.log('[ModelStateService] Migrated global model selections');
|
||||
}
|
||||
|
||||
// Mark migration as complete by removing legacy data
|
||||
this.store.delete(`users.${userId}`);
|
||||
console.log('[ModelStateService] Migration completed and legacy data cleaned up');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ModelStateService] Migration failed:', error);
|
||||
// Don't throw - continue with normal operation
|
||||
}
|
||||
}
|
||||
|
||||
async _loadStateFromDatabase() {
|
||||
console.log('[ModelStateService] Loading state from database...');
|
||||
const userId = this.authService.getCurrentUserId();
|
||||
|
||||
try {
|
||||
// Load provider settings
|
||||
const providerSettings = await providerSettingsRepository.getAllByUid();
|
||||
const apiKeys = {};
|
||||
|
||||
// Reconstruct apiKeys object
|
||||
Object.keys(PROVIDERS).forEach(provider => {
|
||||
apiKeys[provider] = null;
|
||||
});
|
||||
|
||||
for (const setting of providerSettings) {
|
||||
if (setting.api_key) {
|
||||
// API keys are stored encrypted in database, decrypt them
|
||||
if (setting.provider !== 'ollama' && setting.provider !== 'whisper') {
|
||||
try {
|
||||
apiKeys[setting.provider] = encryptionService.decrypt(setting.api_key);
|
||||
} catch (error) {
|
||||
console.error(`[ModelStateService] Failed to decrypt API key for ${setting.provider}, resetting`);
|
||||
apiKeys[setting.provider] = null;
|
||||
}
|
||||
} else {
|
||||
apiKeys[setting.provider] = setting.api_key;
|
||||
await providerSettingsRepository.setActiveProvider(null, type);
|
||||
if (!isInitialBoot) {
|
||||
this.emit('state-updated', await this.getLiveState());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load global model selections
|
||||
const modelSelections = await userModelSelectionsRepository.get();
|
||||
const selectedModels = {
|
||||
llm: modelSelections?.selected_llm_model || null,
|
||||
stt: modelSelections?.selected_stt_model || null
|
||||
};
|
||||
|
||||
this.state = {
|
||||
apiKeys,
|
||||
selectedModels
|
||||
};
|
||||
|
||||
console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
|
||||
|
||||
// Auto-select available models after loading state
|
||||
this._autoSelectAvailableModels();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ModelStateService] Failed to load state from database:', error);
|
||||
// Fall back to default state
|
||||
const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => {
|
||||
acc[key] = null;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
this.state = {
|
||||
apiKeys: initialApiKeys,
|
||||
selectedModels: { llm: null, stt: null },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async _loadStateForCurrentUser() {
|
||||
const userId = this.authService.getCurrentUserId();
|
||||
async setFirebaseVirtualKey(virtualKey) {
|
||||
console.log(`[ModelStateService] Setting Firebase virtual key.`);
|
||||
|
||||
// Initialize encryption service for current user
|
||||
await encryptionService.initializeKey(userId);
|
||||
// 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다.
|
||||
const previousSettings = await providerSettingsRepository.getByProvider('openai-glass');
|
||||
const wasPreviouslyConfigured = !!previousSettings?.api_key;
|
||||
|
||||
// Try to load from database first
|
||||
await this._loadStateFromDatabase();
|
||||
// 항상 새로운 가상 키로 업데이트합니다.
|
||||
await this.setApiKey('openai-glass', virtualKey);
|
||||
|
||||
// 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...');
|
||||
const userId = this.authService.getCurrentUserId();
|
||||
|
||||
try {
|
||||
// Save provider settings (API keys)
|
||||
for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) {
|
||||
if (apiKey) {
|
||||
const encryptedKey = (provider !== 'ollama' && provider !== 'whisper')
|
||||
? encryptionService.encrypt(apiKey)
|
||||
: apiKey;
|
||||
|
||||
await providerSettingsRepository.upsert(provider, {
|
||||
api_key: encryptedKey
|
||||
});
|
||||
} else {
|
||||
// Remove empty API keys
|
||||
await providerSettingsRepository.remove(provider);
|
||||
}
|
||||
}
|
||||
|
||||
// Save global model selections
|
||||
const llmProvider = this.state.selectedModels.llm ? this.getProviderForModel('llm', this.state.selectedModels.llm) : null;
|
||||
const sttProvider = this.state.selectedModels.stt ? this.getProviderForModel('stt', this.state.selectedModels.stt) : null;
|
||||
|
||||
if (llmProvider || sttProvider || this.state.selectedModels.llm || this.state.selectedModels.stt) {
|
||||
await userModelSelectionsRepository.upsert({
|
||||
selected_llm_provider: llmProvider,
|
||||
selected_llm_model: this.state.selectedModels.llm,
|
||||
selected_stt_provider: sttProvider,
|
||||
selected_stt_model: this.state.selectedModels.stt
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`[ModelStateService] State saved to database for user: ${userId}`);
|
||||
this._logCurrentSelection();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ModelStateService] Failed to save state to database:', error);
|
||||
// Fall back to electron-store for now
|
||||
this._saveStateToElectronStore();
|
||||
}
|
||||
}
|
||||
|
||||
_saveStateToElectronStore() {
|
||||
console.log('[ModelStateService] Falling back to electron-store...');
|
||||
const userId = this.authService.getCurrentUserId();
|
||||
const stateToSave = {
|
||||
...this.state,
|
||||
apiKeys: { ...this.state.apiKeys }
|
||||
};
|
||||
|
||||
for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
|
||||
if (key && provider !== 'ollama' && provider !== 'whisper') {
|
||||
try {
|
||||
stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
|
||||
} catch (error) {
|
||||
console.error(`[ModelStateService] Failed to encrypt API key for ${provider}`);
|
||||
stateToSave.apiKeys[provider] = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.store.set(`users.${userId}`, stateToSave);
|
||||
console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`);
|
||||
this._logCurrentSelection();
|
||||
}
|
||||
|
||||
async validateApiKey(provider, key) {
|
||||
if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
|
||||
return { success: false, error: 'API key cannot be empty.' };
|
||||
}
|
||||
|
||||
const ProviderClass = getProviderClass(provider);
|
||||
|
||||
if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
|
||||
// Default to success if no specific validator is found
|
||||
console.warn(`[ModelStateService] No validateApiKey function for provider: ${provider}. Assuming valid.`);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ProviderClass.validateApiKey(key);
|
||||
if (result.success) {
|
||||
console.log(`[ModelStateService] API key for ${provider} is valid.`);
|
||||
if (virtualKey) {
|
||||
// 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다.
|
||||
if (!wasPreviouslyConfigured) {
|
||||
console.log('[ModelStateService] First-time setup for openai-glass, setting default models.');
|
||||
const llmModel = PROVIDERS['openai-glass']?.llmModels[0];
|
||||
const sttModel = PROVIDERS['openai-glass']?.sttModels[0];
|
||||
if (llmModel) await this.setSelectedModel('llm', llmModel.id);
|
||||
if (sttModel) await this.setSelectedModel('stt', sttModel.id);
|
||||
} else {
|
||||
console.log(`[ModelStateService] API key for ${provider} is invalid: ${result.error}`);
|
||||
console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.');
|
||||
}
|
||||
} else {
|
||||
// 로그아웃 시, 현재 활성화된 모델이 openai-glass인 경우에만 다른 모델로 전환합니다.
|
||||
const selected = await this.getSelectedModels();
|
||||
const llmProvider = this.getProviderForModel(selected.llm, 'llm');
|
||||
const sttProvider = this.getProviderForModel(selected.stt, 'stt');
|
||||
|
||||
const typesToReselect = [];
|
||||
if (llmProvider === 'openai-glass') typesToReselect.push('llm');
|
||||
if (sttProvider === 'openai-glass') typesToReselect.push('stt');
|
||||
|
||||
if (typesToReselect.length > 0) {
|
||||
console.log('[ModelStateService] Logged out, re-selecting models for:', typesToReselect.join(', '));
|
||||
await this._autoSelectAvailableModels(typesToReselect);
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`[ModelStateService] Error during ${provider} key validation:`, error);
|
||||
return { success: false, error: 'An unexpected error occurred during validation.' };
|
||||
}
|
||||
}
|
||||
|
||||
setFirebaseVirtualKey(virtualKey) {
|
||||
console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`);
|
||||
this.state.apiKeys['openai-glass'] = virtualKey;
|
||||
|
||||
const llmModels = PROVIDERS['openai-glass']?.llmModels;
|
||||
const sttModels = PROVIDERS['openai-glass']?.sttModels;
|
||||
|
||||
// When logging in with Pickle, prioritize Pickle's models over existing selections
|
||||
if (virtualKey && llmModels?.length > 0) {
|
||||
this.state.selectedModels.llm = llmModels[0].id;
|
||||
console.log(`[ModelStateService] Prioritized Pickle LLM model: ${llmModels[0].id}`);
|
||||
}
|
||||
if (virtualKey && sttModels?.length > 0) {
|
||||
this.state.selectedModels.stt = sttModels[0].id;
|
||||
console.log(`[ModelStateService] Prioritized Pickle STT model: ${sttModels[0].id}`);
|
||||
}
|
||||
|
||||
// If logging out (virtualKey is null), run auto-selection to find alternatives
|
||||
if (!virtualKey) {
|
||||
this._autoSelectAvailableModels();
|
||||
}
|
||||
|
||||
this._saveState();
|
||||
this._logCurrentSelection();
|
||||
}
|
||||
|
||||
async setApiKey(provider, key) {
|
||||
console.log(`[ModelStateService] setApiKey: ${provider}`);
|
||||
console.log(`[ModelStateService] setApiKey for ${provider}`);
|
||||
if (!provider) {
|
||||
throw new Error('Provider is required');
|
||||
}
|
||||
|
||||
let finalKey = key;
|
||||
|
||||
// Handle encryption for non-firebase providers
|
||||
if (provider !== 'firebase' && key && key !== 'local') {
|
||||
finalKey = await encryptionService.encrypt(key);
|
||||
// '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;
|
||||
}
|
||||
}
|
||||
|
||||
this.state.apiKeys[provider] = finalKey;
|
||||
await this._saveState();
|
||||
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
|
||||
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
|
||||
await providerSettingsRepository.upsert(provider, { ...existingSettings, api_key: finalKey });
|
||||
|
||||
this._autoSelectAvailableModels([]);
|
||||
// 키가 추가/변경되었으므로, 해당 provider의 모델을 자동 선택할 수 있는지 확인
|
||||
await this._autoSelectAvailableModels([]);
|
||||
|
||||
this._broadcastToAllWindows('model-state:updated', this.state);
|
||||
this._broadcastToAllWindows('settings-updated');
|
||||
this.emit('state-updated', await this.getLiveState());
|
||||
this.emit('settings-updated');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
getApiKey(provider) {
|
||||
return this.state.apiKeys[provider];
|
||||
}
|
||||
|
||||
getAllApiKeys() {
|
||||
const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys;
|
||||
return displayKeys;
|
||||
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) {
|
||||
if (this.state.apiKeys[provider]) {
|
||||
this.state.apiKeys[provider] = null;
|
||||
await providerSettingsRepository.remove(provider);
|
||||
await this._saveState();
|
||||
this._autoSelectAvailableModels([]);
|
||||
this._broadcastToAllWindows('model-state:updated', this.state);
|
||||
this._broadcastToAllWindows('settings-updated');
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
const selectedModel = this.state.selectedModels[type];
|
||||
return this.getProviderForModel(type, selectedModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 Firebase에 로그인했는지 확인합니다.
|
||||
*/
|
||||
isLoggedInWithFirebase() {
|
||||
return this.authService.getCurrentUser().isLoggedIn;
|
||||
}
|
||||
|
||||
areProvidersConfigured() {
|
||||
/**
|
||||
* 유효한 API 키가 하나라도 설정되어 있는지 확인합니다.
|
||||
*/
|
||||
async hasValidApiKey() {
|
||||
if (this.isLoggedInWithFirebase()) return true;
|
||||
|
||||
console.log('[DEBUG] Checking configured providers with apiKeys state:', JSON.stringify(this.state.apiKeys, (key, value) => (value ? '***' : null), 2));
|
||||
|
||||
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
|
||||
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
|
||||
if (provider === 'ollama') {
|
||||
// Ollama uses dynamic models, so just check if configured (has 'local' key)
|
||||
return key === 'local';
|
||||
}
|
||||
if (provider === 'whisper') {
|
||||
// Whisper doesn't support LLM
|
||||
return false;
|
||||
}
|
||||
return key && PROVIDERS[provider]?.llmModels.length > 0;
|
||||
});
|
||||
|
||||
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
|
||||
if (provider === 'whisper') {
|
||||
// Whisper has static model list and supports STT
|
||||
return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0;
|
||||
}
|
||||
if (provider === 'ollama') {
|
||||
// Ollama doesn't support STT yet
|
||||
return false;
|
||||
}
|
||||
return key && PROVIDERS[provider]?.sttModels.length > 0;
|
||||
});
|
||||
|
||||
const result = hasLlmKey && hasSttKey;
|
||||
console.log(`[ModelStateService] areProvidersConfigured: LLM=${hasLlmKey}, STT=${hasSttKey}, result=${result}`);
|
||||
return result;
|
||||
const allSettings = await providerSettingsRepository.getAll();
|
||||
return allSettings.some(s => s.api_key && s.api_key.trim().length > 0);
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
for (const [providerId, key] of Object.entries(this.state.apiKeys)) {
|
||||
if (!key) continue;
|
||||
|
||||
// Ollama의 경우 데이터베이스에서 설치된 모델을 가져오기
|
||||
if (providerId === 'ollama' && type === 'llm') {
|
||||
try {
|
||||
const ollamaModelRepository = require('../repositories/ollamaModel');
|
||||
const installedModels = ollamaModelRepository.getInstalledModels();
|
||||
const ollamaModels = installedModels.map(model => ({
|
||||
id: model.name,
|
||||
name: model.name
|
||||
}));
|
||||
available.push(...ollamaModels);
|
||||
} catch (error) {
|
||||
console.warn('[ModelStateService] Failed to get Ollama models from DB:', error.message);
|
||||
}
|
||||
}
|
||||
// Whisper의 경우 정적 모델 목록 사용 (설치 상태는 별도 확인)
|
||||
else if (providerId === 'whisper' && type === 'stt') {
|
||||
// Whisper 모델은 factory.js의 정적 목록 사용
|
||||
if (PROVIDERS[providerId]?.[modelList]) {
|
||||
available.push(...PROVIDERS[providerId][modelList]);
|
||||
}
|
||||
}
|
||||
// 다른 provider들은 기존 로직 사용
|
||||
else if (PROVIDERS[providerId]?.[modelList]) {
|
||||
available.push(...PROVIDERS[providerId][modelList]);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Map(available.map(item => [item.id, item])).values()];
|
||||
if (type === 'llm') {
|
||||
const installedModels = ollamaModelRepository.getInstalledModels();
|
||||
if (installedModels.some(m => m.name === modelId)) return 'ollama';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getSelectedModels() {
|
||||
return this.state.selectedModels;
|
||||
async getSelectedModels() {
|
||||
const active = await providerSettingsRepository.getActiveSettings();
|
||||
return {
|
||||
llm: active.llm?.selected_llm_model || null,
|
||||
stt: active.stt?.selected_stt_model || null,
|
||||
};
|
||||
}
|
||||
|
||||
setSelectedModel(type, modelId) {
|
||||
const availableModels = this.getAvailableModels(type);
|
||||
const isAvailable = availableModels.some(model => model.id === modelId);
|
||||
|
||||
if (!isAvailable) {
|
||||
console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`);
|
||||
async setSelectedModel(type, modelId) {
|
||||
const provider = this.getProviderForModel(modelId, type);
|
||||
if (!provider) {
|
||||
console.warn(`[ModelStateService] No provider found for model ${modelId}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const previousModelId = this.state.selectedModels[type];
|
||||
this.state.selectedModels[type] = modelId;
|
||||
this._saveState();
|
||||
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
|
||||
const newSettings = { ...existingSettings };
|
||||
|
||||
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`);
|
||||
|
||||
// Auto warm-up for Ollama models
|
||||
if (type === 'llm' && modelId && modelId !== previousModelId) {
|
||||
const provider = this.getProviderForModel('llm', modelId);
|
||||
if (provider === 'ollama') {
|
||||
this._autoWarmUpOllamaModel(modelId, previousModelId);
|
||||
}
|
||||
if (type === 'llm') {
|
||||
newSettings.selected_llm_model = modelId;
|
||||
} else {
|
||||
newSettings.selected_stt_model = modelId;
|
||||
}
|
||||
|
||||
this._broadcastToAllWindows('model-state:updated', this.state);
|
||||
this._broadcastToAllWindows('settings-updated');
|
||||
await providerSettingsRepository.upsert(provider, newSettings);
|
||||
await providerSettingsRepository.setActiveProvider(provider, type);
|
||||
|
||||
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (provider: ${provider})`);
|
||||
|
||||
if (type === 'llm' && provider === 'ollama') {
|
||||
require('./localAIManager').warmUpModel(modelId).catch(err => console.warn(err));
|
||||
}
|
||||
|
||||
this.emit('state-updated', await this.getLiveState());
|
||||
this.emit('settings-updated');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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`);
|
||||
async getAvailableModels(type) {
|
||||
const allSettings = await providerSettingsRepository.getAll();
|
||||
const available = [];
|
||||
const modelListKey = type === 'llm' ? 'llmModels' : 'sttModels';
|
||||
|
||||
// Get Ollama service if available
|
||||
const ollamaService = require('./ollamaService');
|
||||
if (!ollamaService) {
|
||||
console.log('[ModelStateService] OllamaService not available for auto warm-up');
|
||||
return;
|
||||
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()];
|
||||
}
|
||||
|
||||
// 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);
|
||||
async getCurrentModelInfo(type) {
|
||||
const activeSetting = await providerSettingsRepository.getActiveProvider(type);
|
||||
if (!activeSetting) return null;
|
||||
|
||||
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
|
||||
const model = type === 'llm' ? activeSetting.selected_llm_model : activeSetting.selected_stt_model;
|
||||
if (!model) return null;
|
||||
|
||||
return {
|
||||
provider: activeSetting.provider,
|
||||
model: model,
|
||||
apiKey: activeSetting.api_key,
|
||||
};
|
||||
}
|
||||
|
||||
// --- 핸들러 및 유틸리티 메서드 ---
|
||||
|
||||
async validateApiKey(provider, key) {
|
||||
if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
|
||||
return { success: false, error: 'API key cannot be empty.' };
|
||||
}
|
||||
const ProviderClass = getProviderClass(provider);
|
||||
if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
|
||||
return { success: true };
|
||||
}
|
||||
try {
|
||||
return await ProviderClass.validateApiKey(key);
|
||||
} catch (error) {
|
||||
console.error('[ModelStateService] Error in auto warm-up setup:', error);
|
||||
return { success: false, error: 'An unexpected error occurred during validation.' };
|
||||
}
|
||||
}
|
||||
|
||||
getProviderConfig() {
|
||||
const serializableProviders = {};
|
||||
const config = {};
|
||||
for (const key in PROVIDERS) {
|
||||
const { handler, ...rest } = PROVIDERS[key];
|
||||
serializableProviders[key] = rest;
|
||||
config[key] = rest;
|
||||
}
|
||||
return serializableProviders;
|
||||
}
|
||||
|
||||
async handleValidateKey(provider, key) {
|
||||
const result = await this.validateApiKey(provider, key);
|
||||
if (result.success) {
|
||||
// Use 'local' as placeholder for local services
|
||||
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
|
||||
await this.setApiKey(provider, finalKey);
|
||||
}
|
||||
return result;
|
||||
return config;
|
||||
}
|
||||
|
||||
async handleRemoveApiKey(provider) {
|
||||
console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`);
|
||||
const success = await this.removeApiKey(provider);
|
||||
if (success) {
|
||||
const selectedModels = this.getSelectedModels();
|
||||
if (!selectedModels.llm || !selectedModels.stt) {
|
||||
this._broadcastToAllWindows('force-show-apikey-header');
|
||||
const selectedModels = await this.getSelectedModels();
|
||||
if (!selectedModels.llm && !selectedModels.stt) {
|
||||
this.emit('force-show-apikey-header');
|
||||
}
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/*-------------- Compatibility Helpers --------------*/
|
||||
async handleValidateKey(provider, key) {
|
||||
return await this.setApiKey(provider, key);
|
||||
}
|
||||
|
||||
async handleSetSelectedModel(type, modelId) {
|
||||
return this.setSelectedModel(type, modelId);
|
||||
return await this.setSelectedModel(type, modelId);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @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 };
|
||||
async areProvidersConfigured() {
|
||||
if (this.isLoggedInWithFirebase()) return true;
|
||||
const allSettings = await providerSettingsRepository.getAll();
|
||||
const apiKeyMap = {};
|
||||
allSettings.forEach(s => apiKeyMap[s.provider] = s.api_key);
|
||||
// LLM
|
||||
const hasLlmKey = Object.entries(apiKeyMap).some(([provider, key]) => {
|
||||
if (!key) return false;
|
||||
if (provider === 'whisper') return false; // whisper는 LLM 없음
|
||||
return PROVIDERS[provider]?.llmModels?.length > 0;
|
||||
});
|
||||
// STT
|
||||
const hasSttKey = Object.entries(apiKeyMap).some(([provider, key]) => {
|
||||
if (!key) return false;
|
||||
if (provider === 'ollama') return false; // ollama는 STT 없음
|
||||
return PROVIDERS[provider]?.sttModels?.length > 0 || provider === 'whisper';
|
||||
});
|
||||
return hasLlmKey && hasSttKey;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
const modelStateService = new ModelStateService();
|
||||
module.exports = modelStateService;
|
File diff suppressed because it is too large
Load Diff
@ -2,27 +2,28 @@ 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') {
|
||||
const micStatus = systemPreferences.getMediaAccessStatus('microphone');
|
||||
console.log('[Permissions] Microphone status:', micStatus);
|
||||
permissions.microphone = micStatus;
|
||||
|
||||
const screenStatus = systemPreferences.getMediaAccessStatus('screen');
|
||||
console.log('[Permissions] Screen status:', screenStatus);
|
||||
permissions.screen = screenStatus;
|
||||
|
||||
permissions.needsSetup = micStatus !== 'granted' || screenStatus !== 'granted';
|
||||
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;
|
||||
}
|
||||
|
||||
@ -33,6 +34,7 @@ class PermissionService {
|
||||
return {
|
||||
microphone: 'unknown',
|
||||
screen: 'unknown',
|
||||
keychain: 'unknown',
|
||||
needsSetup: true,
|
||||
error: error.message
|
||||
};
|
||||
@ -92,24 +94,27 @@ class PermissionService {
|
||||
}
|
||||
}
|
||||
|
||||
async markPermissionsAsCompleted() {
|
||||
async markKeychainCompleted() {
|
||||
try {
|
||||
await permissionRepository.markPermissionsAsCompleted();
|
||||
console.log('[Permissions] Marked permissions as completed');
|
||||
await permissionRepository.markKeychainCompleted(this._getAuthService().getCurrentUserId());
|
||||
console.log('[Permissions] Marked keychain as completed');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error marking permissions as completed:', error);
|
||||
console.error('[Permissions] Error marking keychain as completed:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async checkPermissionsCompleted() {
|
||||
async checkKeychainCompleted(uid) {
|
||||
if (uid === "default_user") {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const completed = await permissionRepository.checkPermissionsCompleted();
|
||||
console.log('[Permissions] Permissions completed status:', completed);
|
||||
const completed = permissionRepository.checkKeychainCompleted(uid);
|
||||
console.log('[Permissions] Keychain completed status:', completed);
|
||||
return completed;
|
||||
} catch (error) {
|
||||
console.error('[Permissions] Error checking permissions completed status:', error);
|
||||
console.error('[Permissions] Error checking keychain completed status:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -40,8 +40,82 @@ class SQLiteClient {
|
||||
return `"${identifier}"`;
|
||||
}
|
||||
|
||||
synchronizeSchema() {
|
||||
_migrateProviderSettings() {
|
||||
const tablesInDb = this.getTablesFromDb();
|
||||
if (!tablesInDb.includes('provider_settings')) {
|
||||
return; // Table doesn't exist, no migration needed.
|
||||
}
|
||||
|
||||
const providerSettingsInfo = this.db.prepare(`PRAGMA table_info(provider_settings)`).all();
|
||||
const hasUidColumn = providerSettingsInfo.some(col => col.name === 'uid');
|
||||
|
||||
if (hasUidColumn) {
|
||||
console.log('[DB Migration] Old provider_settings schema detected. Starting robust migration...');
|
||||
|
||||
try {
|
||||
this.db.transaction(() => {
|
||||
this.db.exec('ALTER TABLE provider_settings RENAME TO provider_settings_old');
|
||||
console.log('[DB Migration] Renamed provider_settings to provider_settings_old');
|
||||
|
||||
this.createTable('provider_settings', LATEST_SCHEMA.provider_settings);
|
||||
console.log('[DB Migration] Created new provider_settings table');
|
||||
|
||||
// Dynamically build the migration query for robustness
|
||||
const oldColumnNames = this.db.prepare(`PRAGMA table_info(provider_settings_old)`).all().map(c => c.name);
|
||||
const newColumnNames = LATEST_SCHEMA.provider_settings.columns.map(c => c.name);
|
||||
const commonColumns = newColumnNames.filter(name => oldColumnNames.includes(name));
|
||||
|
||||
if (!commonColumns.includes('provider')) {
|
||||
console.warn('[DB Migration] Old table is missing the "provider" column. Aborting migration for this table.');
|
||||
this.db.exec('DROP TABLE provider_settings_old');
|
||||
return;
|
||||
}
|
||||
|
||||
const orderParts = [];
|
||||
if (oldColumnNames.includes('updated_at')) orderParts.push('updated_at DESC');
|
||||
if (oldColumnNames.includes('created_at')) orderParts.push('created_at DESC');
|
||||
const orderByClause = orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : '';
|
||||
|
||||
const columnsForInsert = commonColumns.map(c => this._validateAndQuoteIdentifier(c)).join(', ');
|
||||
|
||||
const migrationQuery = `
|
||||
INSERT INTO provider_settings (${columnsForInsert})
|
||||
SELECT ${columnsForInsert}
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER(PARTITION BY provider ${orderByClause}) as rn
|
||||
FROM provider_settings_old
|
||||
)
|
||||
WHERE rn = 1
|
||||
`;
|
||||
|
||||
console.log(`[DB Migration] Executing robust migration query for columns: ${commonColumns.join(', ')}`);
|
||||
const result = this.db.prepare(migrationQuery).run();
|
||||
console.log(`[DB Migration] Migrated ${result.changes} rows to the new provider_settings table.`);
|
||||
|
||||
this.db.exec('DROP TABLE provider_settings_old');
|
||||
console.log('[DB Migration] Dropped provider_settings_old table.');
|
||||
})();
|
||||
console.log('[DB Migration] provider_settings migration completed successfully.');
|
||||
} catch (error) {
|
||||
console.error('[DB Migration] Failed to migrate provider_settings table.', error);
|
||||
|
||||
// Try to recover by dropping the temp table if it exists
|
||||
const oldTableExists = this.getTablesFromDb().includes('provider_settings_old');
|
||||
if (oldTableExists) {
|
||||
this.db.exec('DROP TABLE provider_settings_old');
|
||||
console.warn('[DB Migration] Cleaned up temporary old table after failure.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async synchronizeSchema() {
|
||||
console.log('[DB Sync] Starting schema synchronization...');
|
||||
|
||||
// Run special migration for provider_settings before the generic sync logic
|
||||
this._migrateProviderSettings();
|
||||
|
||||
const tablesInDb = this.getTablesFromDb();
|
||||
|
||||
for (const tableName of Object.keys(LATEST_SCHEMA)) {
|
||||
@ -132,8 +206,8 @@ class SQLiteClient {
|
||||
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
|
||||
}
|
||||
|
||||
initTables() {
|
||||
this.synchronizeSchema();
|
||||
async initTables() {
|
||||
await this.synchronizeSchema();
|
||||
this.initDefaultData();
|
||||
}
|
||||
|
||||
@ -166,21 +240,6 @@ class SQLiteClient {
|
||||
console.log('Default data initialized.');
|
||||
}
|
||||
|
||||
markPermissionsAsCompleted() {
|
||||
return this.query(
|
||||
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
|
||||
['permissions_completed', 'true']
|
||||
);
|
||||
}
|
||||
|
||||
checkPermissionsCompleted() {
|
||||
const result = this.query(
|
||||
'SELECT value FROM system_settings WHERE key = ?',
|
||||
['permissions_completed']
|
||||
);
|
||||
return result.length > 0 && result[0].value === 'true';
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
try {
|
||||
|
@ -1,21 +1,40 @@
|
||||
const { spawn } = require('child_process');
|
||||
const { EventEmitter } = require('events');
|
||||
const { spawn, exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { BrowserWindow } = require('electron');
|
||||
const LocalAIServiceBase = require('./localAIServiceBase');
|
||||
const https = require('https');
|
||||
const crypto = require('crypto');
|
||||
const { spawnAsync } = require('../utils/spawnHelper');
|
||||
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const fsPromises = fs.promises;
|
||||
|
||||
class WhisperService extends LocalAIServiceBase {
|
||||
class WhisperService extends EventEmitter {
|
||||
constructor() {
|
||||
super('WhisperService');
|
||||
this.isInitialized = false;
|
||||
super();
|
||||
this.serviceName = 'WhisperService';
|
||||
|
||||
// 경로 및 디렉토리
|
||||
this.whisperPath = null;
|
||||
this.modelsDir = null;
|
||||
this.tempDir = null;
|
||||
|
||||
// 세션 관리 (세션 풀 내장)
|
||||
this.sessionPool = [];
|
||||
this.activeSessions = new Map();
|
||||
this.maxSessions = 3;
|
||||
|
||||
// 설치 상태
|
||||
this.installState = {
|
||||
isInstalled: false,
|
||||
isInitialized: false
|
||||
};
|
||||
|
||||
// 사용 가능한 모델
|
||||
this.availableModels = {
|
||||
'whisper-tiny': {
|
||||
name: 'Tiny',
|
||||
@ -40,21 +59,222 @@ class WhisperService extends LocalAIServiceBase {
|
||||
};
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
|
||||
// Base class methods integration
|
||||
getPlatform() {
|
||||
return process.platform;
|
||||
}
|
||||
|
||||
async checkCommand(command) {
|
||||
try {
|
||||
const platform = this.getPlatform();
|
||||
const checkCmd = platform === 'win32' ? 'where' : 'which';
|
||||
const { stdout } = await execAsync(`${checkCmd} ${command}`);
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) {
|
||||
for (let i = 0; i < maxAttempts; i++) {
|
||||
if (await checkFn()) {
|
||||
console.log(`[${this.serviceName}] Service is ready`);
|
||||
return true;
|
||||
}
|
||||
await new Promise(resolve => setTimeout(resolve, delayMs));
|
||||
}
|
||||
throw new Error(`${this.serviceName} service failed to start within timeout`);
|
||||
}
|
||||
|
||||
async downloadFile(url, destination, options = {}) {
|
||||
const {
|
||||
onProgress = null,
|
||||
headers = { 'User-Agent': 'Glass-App' },
|
||||
timeout = 300000,
|
||||
modelId = null
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const file = fs.createWriteStream(destination);
|
||||
let downloadedSize = 0;
|
||||
let totalSize = 0;
|
||||
|
||||
const request = https.get(url, { headers }, (response) => {
|
||||
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
||||
file.close();
|
||||
fs.unlink(destination, () => {});
|
||||
|
||||
if (!response.headers.location) {
|
||||
reject(new Error('Redirect without location header'));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`);
|
||||
this.downloadFile(response.headers.location, destination, options)
|
||||
.then(resolve)
|
||||
.catch(reject);
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
file.close();
|
||||
fs.unlink(destination, () => {});
|
||||
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`));
|
||||
return;
|
||||
}
|
||||
|
||||
totalSize = parseInt(response.headers['content-length'], 10) || 0;
|
||||
|
||||
response.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
|
||||
if (totalSize > 0) {
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
|
||||
if (onProgress) {
|
||||
onProgress(progress, downloadedSize, totalSize);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.pipe(file);
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
resolve({ success: true, size: downloadedSize });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
request.on('timeout', () => {
|
||||
request.destroy();
|
||||
file.close();
|
||||
fs.unlink(destination, () => {});
|
||||
reject(new Error('Download timeout'));
|
||||
});
|
||||
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
fs.unlink(destination, () => {});
|
||||
this.emit('download-error', { url, error: err, modelId });
|
||||
reject(err);
|
||||
});
|
||||
|
||||
request.setTimeout(timeout);
|
||||
|
||||
file.on('error', (err) => {
|
||||
fs.unlink(destination, () => {});
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async downloadWithRetry(url, destination, options = {}) {
|
||||
const {
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
expectedChecksum = null,
|
||||
modelId = null,
|
||||
...downloadOptions
|
||||
} = options;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await this.downloadFile(url, destination, {
|
||||
...downloadOptions,
|
||||
modelId
|
||||
});
|
||||
|
||||
if (expectedChecksum) {
|
||||
const isValid = await this.verifyChecksum(destination, expectedChecksum);
|
||||
if (!isValid) {
|
||||
fs.unlinkSync(destination);
|
||||
throw new Error('Checksum verification failed');
|
||||
}
|
||||
console.log(`[${this.serviceName}] Checksum verified successfully`);
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
|
||||
await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async verifyChecksum(filePath, expectedChecksum) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const stream = fs.createReadStream(filePath);
|
||||
|
||||
stream.on('data', (data) => hash.update(data));
|
||||
stream.on('end', () => {
|
||||
const fileChecksum = hash.digest('hex');
|
||||
console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`);
|
||||
console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`);
|
||||
resolve(fileChecksum === expectedChecksum);
|
||||
});
|
||||
stream.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
async autoInstall(onProgress) {
|
||||
const platform = this.getPlatform();
|
||||
console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`);
|
||||
|
||||
try {
|
||||
switch(platform) {
|
||||
case 'darwin':
|
||||
return await this.installMacOS(onProgress);
|
||||
case 'win32':
|
||||
return await this.installWindows(onProgress);
|
||||
case 'linux':
|
||||
return await this.installLinux();
|
||||
default:
|
||||
throw new Error(`Unsupported platform: ${platform}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${this.serviceName}] Auto-installation failed:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async shutdown(force = false) {
|
||||
console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`);
|
||||
|
||||
const isRunning = await this.isServiceRunning();
|
||||
if (!isRunning) {
|
||||
console.log(`[${this.serviceName}] Service not running, nothing to shutdown`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const platform = this.getPlatform();
|
||||
|
||||
try {
|
||||
switch(platform) {
|
||||
case 'darwin':
|
||||
return await this.shutdownMacOS(force);
|
||||
case 'win32':
|
||||
return await this.shutdownWindows(force);
|
||||
case 'linux':
|
||||
return await this.shutdownLinux(force);
|
||||
default:
|
||||
console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[${this.serviceName}] Error during shutdown:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
if (this.installState.isInitialized) return;
|
||||
|
||||
try {
|
||||
const homeDir = os.homedir();
|
||||
@ -65,16 +285,21 @@ class WhisperService extends LocalAIServiceBase {
|
||||
|
||||
// Windows에서는 .exe 확장자 필요
|
||||
const platform = this.getPlatform();
|
||||
const whisperExecutable = platform === 'win32' ? 'whisper.exe' : 'whisper';
|
||||
const whisperExecutable = platform === 'win32' ? 'whisper-whisper.exe' : 'whisper';
|
||||
this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable);
|
||||
|
||||
await this.ensureDirectories();
|
||||
await this.ensureWhisperBinary();
|
||||
|
||||
this.isInitialized = true;
|
||||
this.installState.isInitialized = true;
|
||||
console.log('[WhisperService] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[WhisperService] Initialization failed:', error);
|
||||
// Emit error event - LocalAIManager가 처리
|
||||
this.emit('error', {
|
||||
errorType: 'initialization-failed',
|
||||
error: error.message
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@ -85,6 +310,56 @@ class WhisperService extends LocalAIServiceBase {
|
||||
await fsPromises.mkdir(path.dirname(this.whisperPath), { recursive: true });
|
||||
}
|
||||
|
||||
// local stt session
|
||||
async getSession(config) {
|
||||
// check available session
|
||||
const availableSession = this.sessionPool.find(s => !s.inUse);
|
||||
if (availableSession) {
|
||||
availableSession.inUse = true;
|
||||
await availableSession.reconfigure(config);
|
||||
return availableSession;
|
||||
}
|
||||
|
||||
// create new session
|
||||
if (this.activeSessions.size >= this.maxSessions) {
|
||||
throw new Error('Maximum session limit reached');
|
||||
}
|
||||
|
||||
const session = new WhisperSession(config, this);
|
||||
await session.initialize();
|
||||
this.activeSessions.set(session.id, session);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
async releaseSession(sessionId) {
|
||||
const session = this.activeSessions.get(sessionId);
|
||||
if (session) {
|
||||
await session.cleanup();
|
||||
session.inUse = false;
|
||||
|
||||
// add to session pool
|
||||
if (this.sessionPool.length < 2) {
|
||||
this.sessionPool.push(session);
|
||||
} else {
|
||||
// remove session
|
||||
await session.destroy();
|
||||
this.activeSessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//cleanup
|
||||
async cleanup() {
|
||||
// cleanup all sessions
|
||||
for (const session of this.activeSessions.values()) {
|
||||
await session.destroy();
|
||||
}
|
||||
|
||||
this.activeSessions.clear();
|
||||
this.sessionPool = [];
|
||||
}
|
||||
|
||||
async ensureWhisperBinary() {
|
||||
const whisperCliPath = await this.checkCommand('whisper-cli');
|
||||
if (whisperCliPath) {
|
||||
@ -113,6 +388,11 @@ class WhisperService extends LocalAIServiceBase {
|
||||
console.log('[WhisperService] Whisper not found, trying Homebrew installation...');
|
||||
try {
|
||||
await this.installViaHomebrew();
|
||||
// verify installation
|
||||
const verified = await this.verifyInstallation();
|
||||
if (!verified.success) {
|
||||
throw new Error(verified.error);
|
||||
}
|
||||
return;
|
||||
} catch (error) {
|
||||
console.log('[WhisperService] Homebrew installation failed:', error.message);
|
||||
@ -120,6 +400,12 @@ class WhisperService extends LocalAIServiceBase {
|
||||
}
|
||||
|
||||
await this.autoInstall();
|
||||
|
||||
// verify installation
|
||||
const verified = await this.verifyInstallation();
|
||||
if (!verified.success) {
|
||||
throw new Error(`Whisper installation verification failed: ${verified.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
async installViaHomebrew() {
|
||||
@ -146,7 +432,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
|
||||
|
||||
async ensureModelAvailable(modelId) {
|
||||
if (!this.isInitialized) {
|
||||
if (!this.installState.isInitialized) {
|
||||
console.log('[WhisperService] Service not initialized, initializing now...');
|
||||
await this.initialize();
|
||||
}
|
||||
@ -171,25 +457,33 @@ class WhisperService extends LocalAIServiceBase {
|
||||
const modelPath = await this.getModelPath(modelId);
|
||||
const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
|
||||
|
||||
this._broadcastToAllWindows('whisper:download-progress', { modelId, progress: 0 });
|
||||
// Emit progress event - LocalAIManager가 처리
|
||||
this.emit('install-progress', {
|
||||
model: modelId,
|
||||
progress: 0
|
||||
});
|
||||
|
||||
await this.downloadWithRetry(modelInfo.url, modelPath, {
|
||||
expectedChecksum: checksumInfo?.sha256,
|
||||
modelId, // modelId를 전달하여 LocalAIServiceBase에서 이벤트 발생 시 사용
|
||||
modelId, // pass modelId to LocalAIServiceBase for event handling
|
||||
onProgress: (progress) => {
|
||||
this._broadcastToAllWindows('whisper:download-progress', { modelId, progress });
|
||||
// Emit progress event - LocalAIManager가 처리
|
||||
this.emit('install-progress', {
|
||||
model: modelId,
|
||||
progress
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
|
||||
this._broadcastToAllWindows('whisper:download-complete', { modelId });
|
||||
this.emit('model-download-complete', { modelId });
|
||||
}
|
||||
|
||||
async handleDownloadModel(modelId) {
|
||||
try {
|
||||
console.log(`[WhisperService] Handling download for model: ${modelId}`);
|
||||
|
||||
if (!this.isInitialized) {
|
||||
if (!this.installState.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
@ -204,7 +498,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
|
||||
async handleGetInstalledModels() {
|
||||
try {
|
||||
if (!this.isInitialized) {
|
||||
if (!this.installState.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
const models = await this.getInstalledModels();
|
||||
@ -216,7 +510,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
}
|
||||
|
||||
async getModelPath(modelId) {
|
||||
if (!this.isInitialized || !this.modelsDir) {
|
||||
if (!this.installState.isInitialized || !this.modelsDir) {
|
||||
throw new Error('WhisperService is not initialized. Call initialize() first.');
|
||||
}
|
||||
return path.join(this.modelsDir, `${modelId}.bin`);
|
||||
@ -241,7 +535,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
|
||||
createWavHeader(dataSize) {
|
||||
const header = Buffer.alloc(44);
|
||||
const sampleRate = 24000;
|
||||
const sampleRate = 16000;
|
||||
const numChannels = 1;
|
||||
const bitsPerSample = 16;
|
||||
|
||||
@ -290,7 +584,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
}
|
||||
|
||||
async getInstalledModels() {
|
||||
if (!this.isInitialized) {
|
||||
if (!this.installState.isInitialized) {
|
||||
console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...');
|
||||
await this.initialize();
|
||||
}
|
||||
@ -319,11 +613,11 @@ class WhisperService extends LocalAIServiceBase {
|
||||
}
|
||||
|
||||
async isServiceRunning() {
|
||||
return this.isInitialized;
|
||||
return this.installState.isInitialized;
|
||||
}
|
||||
|
||||
async startService() {
|
||||
if (!this.isInitialized) {
|
||||
if (!this.installState.isInitialized) {
|
||||
await this.initialize();
|
||||
}
|
||||
return true;
|
||||
@ -349,7 +643,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
async installWindows() {
|
||||
console.log('[WhisperService] Installing Whisper on Windows...');
|
||||
const version = 'v1.7.6';
|
||||
const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-x64.zip`;
|
||||
const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-bin-x64.zip`;
|
||||
const tempFile = path.join(this.tempDir, 'whisper-binary.zip');
|
||||
|
||||
try {
|
||||
@ -427,8 +721,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
if (item.isDirectory()) {
|
||||
const subExecutables = await this.findWhisperExecutables(fullPath);
|
||||
executables.push(...subExecutables);
|
||||
} else if (item.isFile() && (item.name === 'whisper.exe' || item.name === 'main.exe')) {
|
||||
// main.exe도 포함 (일부 빌드에서 whisper 실행파일이 main.exe로 명명됨)
|
||||
} else if (item.isFile() && (item.name === 'whisper-whisper.exe' || item.name === 'whisper.exe' || item.name === 'main.exe')) {
|
||||
executables.push(fullPath);
|
||||
}
|
||||
}
|
||||
@ -463,7 +756,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
async installLinux() {
|
||||
console.log('[WhisperService] Installing Whisper on Linux...');
|
||||
const version = 'v1.7.6';
|
||||
const binaryUrl = `https://github.com/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
|
||||
const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
|
||||
const tempFile = path.join(this.tempDir, 'whisper-binary.tar.gz');
|
||||
|
||||
try {
|
||||
@ -493,6 +786,92 @@ class WhisperService extends LocalAIServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
@ -4,6 +4,7 @@ const SummaryService = require('./summary/summaryService');
|
||||
const authService = require('../common/services/authService');
|
||||
const sessionRepository = require('../common/repositories/session');
|
||||
const sttRepository = require('./stt/repositories');
|
||||
const internalBridge = require('../../bridge/internalBridge');
|
||||
|
||||
class ListenService {
|
||||
constructor() {
|
||||
@ -53,7 +54,7 @@ class ListenService {
|
||||
}
|
||||
|
||||
async handleListenRequest(listenButtonText) {
|
||||
const { windowPool, updateLayout } = require('../../window/windowManager');
|
||||
const { windowPool } = require('../../window/windowManager');
|
||||
const listenWindow = windowPool.get('listen');
|
||||
const header = windowPool.get('header');
|
||||
|
||||
@ -61,22 +62,24 @@ class ListenService {
|
||||
switch (listenButtonText) {
|
||||
case 'Listen':
|
||||
console.log('[ListenService] changeSession to "Listen"');
|
||||
listenWindow.show();
|
||||
updateLayout();
|
||||
listenWindow.webContents.send('window-show-animation');
|
||||
internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true });
|
||||
await this.initializeSession();
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: true });
|
||||
if (listenWindow && !listenWindow.isDestroyed()) {
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: true });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Stop':
|
||||
console.log('[ListenService] changeSession to "Stop"');
|
||||
await this.closeSession();
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: false });
|
||||
if (listenWindow && !listenWindow.isDestroyed()) {
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: false });
|
||||
}
|
||||
break;
|
||||
|
||||
case 'Done':
|
||||
console.log('[ListenService] changeSession to "Done"');
|
||||
listenWindow.webContents.send('window-hide-animation');
|
||||
internalBridge.emit('window:requestVisibility', { name: 'listen', visible: false });
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: false });
|
||||
break;
|
||||
|
||||
|
@ -2,7 +2,6 @@ const { BrowserWindow } = require('electron');
|
||||
const { spawn } = require('child_process');
|
||||
const { createSTT } = require('../../common/ai/factory');
|
||||
const modelStateService = require('../../common/services/modelStateService');
|
||||
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager');
|
||||
|
||||
const COMPLETION_DEBOUNCE_MS = 2000;
|
||||
|
||||
@ -134,7 +133,7 @@ class SttService {
|
||||
async initializeSttSessions(language = 'en') {
|
||||
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
|
||||
|
||||
const modelInfo = modelStateService.getCurrentModelInfo('stt');
|
||||
const modelInfo = await modelStateService.getCurrentModelInfo('stt');
|
||||
if (!modelInfo || !modelInfo.apiKey) {
|
||||
throw new Error('AI model or API key is not configured.');
|
||||
}
|
||||
@ -146,7 +145,7 @@ class SttService {
|
||||
console.log('[SttService] Ignoring message - session already closed');
|
||||
return;
|
||||
}
|
||||
console.log('[SttService] handleMyMessage', message);
|
||||
// console.log('[SttService] handleMyMessage', message);
|
||||
|
||||
if (this.modelInfo.provider === 'whisper') {
|
||||
// Whisper STT emits 'transcription' events with different structure
|
||||
@ -167,10 +166,6 @@ class SttService {
|
||||
'(NOISE)'
|
||||
];
|
||||
|
||||
|
||||
|
||||
const normalizedText = finalText.toLowerCase().trim();
|
||||
|
||||
const isNoise = noisePatterns.some(pattern =>
|
||||
finalText.includes(pattern) || finalText === pattern
|
||||
);
|
||||
@ -221,6 +216,38 @@ class SttService {
|
||||
isFinal: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Deepgram
|
||||
} else if (this.modelInfo.provider === 'deepgram') {
|
||||
const text = message.channel?.alternatives?.[0]?.transcript;
|
||||
if (!text || text.trim().length === 0) return;
|
||||
|
||||
const isFinal = message.is_final;
|
||||
console.log(`[SttService-Me-Deepgram] Received: isFinal=${isFinal}, text="${text}"`);
|
||||
|
||||
if (isFinal) {
|
||||
// 최종 결과가 도착하면, 현재 진행중인 부분 발화는 비우고
|
||||
// 최종 텍스트로 debounce를 실행합니다.
|
||||
this.myCurrentUtterance = '';
|
||||
this.debounceMyCompletion(text);
|
||||
} else {
|
||||
// 부분 결과(interim)인 경우, 화면에 실시간으로 업데이트합니다.
|
||||
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
|
||||
this.myCompletionTimer = null;
|
||||
|
||||
this.myCurrentUtterance = text;
|
||||
|
||||
const continuousText = (this.myCompletionBuffer + ' ' + this.myCurrentUtterance).trim();
|
||||
|
||||
this.sendToRenderer('stt-update', {
|
||||
speaker: 'Me',
|
||||
text: continuousText,
|
||||
isPartial: true,
|
||||
isFinal: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
const type = message.type;
|
||||
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
|
||||
@ -280,9 +307,6 @@ class SttService {
|
||||
'(NOISE)'
|
||||
];
|
||||
|
||||
|
||||
const normalizedText = finalText.toLowerCase().trim();
|
||||
|
||||
const isNoise = noisePatterns.some(pattern =>
|
||||
finalText.includes(pattern) || finalText === pattern
|
||||
);
|
||||
@ -334,6 +358,34 @@ class SttService {
|
||||
isFinal: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Deepgram
|
||||
} else if (this.modelInfo.provider === 'deepgram') {
|
||||
const text = message.channel?.alternatives?.[0]?.transcript;
|
||||
if (!text || text.trim().length === 0) return;
|
||||
|
||||
const isFinal = message.is_final;
|
||||
|
||||
if (isFinal) {
|
||||
this.theirCurrentUtterance = '';
|
||||
this.debounceTheirCompletion(text);
|
||||
} else {
|
||||
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
|
||||
this.theirCompletionTimer = null;
|
||||
|
||||
this.theirCurrentUtterance = text;
|
||||
|
||||
const continuousText = (this.theirCompletionBuffer + ' ' + this.theirCurrentUtterance).trim();
|
||||
|
||||
this.sendToRenderer('stt-update', {
|
||||
speaker: 'Them',
|
||||
text: continuousText,
|
||||
isPartial: true,
|
||||
isFinal: false,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
} else {
|
||||
const type = message.type;
|
||||
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
|
||||
@ -414,16 +466,20 @@ class SttService {
|
||||
let modelInfo = this.modelInfo;
|
||||
if (!modelInfo) {
|
||||
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
|
||||
modelInfo = modelStateService.getCurrentModelInfo('stt');
|
||||
modelInfo = await modelStateService.getCurrentModelInfo('stt');
|
||||
}
|
||||
if (!modelInfo) {
|
||||
throw new Error('STT model info could not be retrieved.');
|
||||
}
|
||||
|
||||
const payload = modelInfo.provider === 'gemini'
|
||||
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
|
||||
: data;
|
||||
|
||||
let payload;
|
||||
if (modelInfo.provider === 'gemini') {
|
||||
payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
|
||||
} else if (modelInfo.provider === 'deepgram') {
|
||||
payload = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
payload = data;
|
||||
}
|
||||
await this.mySttSession.sendRealtimeInput(payload);
|
||||
}
|
||||
|
||||
@ -435,15 +491,20 @@ class SttService {
|
||||
let modelInfo = this.modelInfo;
|
||||
if (!modelInfo) {
|
||||
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
|
||||
modelInfo = modelStateService.getCurrentModelInfo('stt');
|
||||
modelInfo = await modelStateService.getCurrentModelInfo('stt');
|
||||
}
|
||||
if (!modelInfo) {
|
||||
throw new Error('STT model info could not be retrieved.');
|
||||
}
|
||||
|
||||
const payload = modelInfo.provider === 'gemini'
|
||||
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
|
||||
: data;
|
||||
let payload;
|
||||
if (modelInfo.provider === 'gemini') {
|
||||
payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
|
||||
} else if (modelInfo.provider === 'deepgram') {
|
||||
payload = Buffer.from(data, 'base64');
|
||||
} else {
|
||||
payload = data;
|
||||
}
|
||||
|
||||
await this.theirSttSession.sendRealtimeInput(payload);
|
||||
}
|
||||
@ -516,7 +577,7 @@ class SttService {
|
||||
let modelInfo = this.modelInfo;
|
||||
if (!modelInfo) {
|
||||
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
|
||||
modelInfo = modelStateService.getCurrentModelInfo('stt');
|
||||
modelInfo = await modelStateService.getCurrentModelInfo('stt');
|
||||
}
|
||||
if (!modelInfo) {
|
||||
throw new Error('STT model info could not be retrieved.');
|
||||
@ -536,9 +597,15 @@ class SttService {
|
||||
|
||||
if (this.theirSttSession) {
|
||||
try {
|
||||
const payload = modelInfo.provider === 'gemini'
|
||||
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
|
||||
: base64Data;
|
||||
let payload;
|
||||
if (modelInfo.provider === 'gemini') {
|
||||
payload = { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } };
|
||||
} else if (modelInfo.provider === 'deepgram') {
|
||||
payload = Buffer.from(base64Data, 'base64');
|
||||
} else {
|
||||
payload = base64Data;
|
||||
}
|
||||
|
||||
await this.theirSttSession.sendRealtimeInput(payload);
|
||||
} catch (err) {
|
||||
console.error('Error sending system audio:', err.message);
|
||||
|
@ -4,7 +4,6 @@ const { createLLM } = require('../../common/ai/factory');
|
||||
const sessionRepository = require('../../common/repositories/session');
|
||||
const summaryRepository = require('./repositories');
|
||||
const modelStateService = require('../../common/services/modelStateService');
|
||||
// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js');
|
||||
|
||||
class SummaryService {
|
||||
constructor() {
|
||||
@ -99,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments.
|
||||
await sessionRepository.touch(this.currentSessionId);
|
||||
}
|
||||
|
||||
const modelInfo = modelStateService.getCurrentModelInfo('llm');
|
||||
const modelInfo = await modelStateService.getCurrentModelInfo('llm');
|
||||
if (!modelInfo || !modelInfo.apiKey) {
|
||||
throw new Error('AI model or API key is not configured.');
|
||||
}
|
||||
|
@ -6,8 +6,7 @@ const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window
|
||||
|
||||
// New imports for common services
|
||||
const modelStateService = require('../common/services/modelStateService');
|
||||
const ollamaService = require('../common/services/ollamaService');
|
||||
const whisperService = require('../common/services/whisperService');
|
||||
const localAIManager = require('../common/services/localAIManager');
|
||||
|
||||
const store = new Store({
|
||||
name: 'pickle-glass-settings',
|
||||
@ -27,16 +26,14 @@ const NOTIFICATION_CONFIG = {
|
||||
// New facade functions for model state management
|
||||
async function getModelSettings() {
|
||||
try {
|
||||
const [config, storedKeys, selectedModels] = await Promise.all([
|
||||
const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([
|
||||
modelStateService.getProviderConfig(),
|
||||
modelStateService.getAllApiKeys(),
|
||||
modelStateService.getSelectedModels(),
|
||||
modelStateService.getAvailableModels('llm'),
|
||||
modelStateService.getAvailableModels('stt')
|
||||
]);
|
||||
|
||||
// 동기 함수들은 별도로 호출
|
||||
const availableLlm = modelStateService.getAvailableModels('llm');
|
||||
const availableStt = modelStateService.getAvailableModels('stt');
|
||||
|
||||
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
|
||||
} catch (error) {
|
||||
console.error('[SettingsService] Error getting model settings:', error);
|
||||
@ -44,10 +41,6 @@ async function getModelSettings() {
|
||||
}
|
||||
}
|
||||
|
||||
async function validateAndSaveKey(provider, key) {
|
||||
return modelStateService.handleValidateKey(provider, key);
|
||||
}
|
||||
|
||||
async function clearApiKey(provider) {
|
||||
const success = await modelStateService.handleRemoveApiKey(provider);
|
||||
return { success };
|
||||
@ -58,17 +51,21 @@ async function setSelectedModel(type, modelId) {
|
||||
return { success };
|
||||
}
|
||||
|
||||
// Ollama facade functions
|
||||
// LocalAI facade functions
|
||||
async function getOllamaStatus() {
|
||||
return ollamaService.getStatus();
|
||||
return localAIManager.getServiceStatus('ollama');
|
||||
}
|
||||
|
||||
async function ensureOllamaReady() {
|
||||
return ollamaService.ensureReady();
|
||||
const status = await localAIManager.getServiceStatus('ollama');
|
||||
if (!status.installed || !status.running) {
|
||||
await localAIManager.startService('ollama');
|
||||
}
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async function shutdownOllama() {
|
||||
return ollamaService.shutdown(false); // false for graceful shutdown
|
||||
return localAIManager.stopService('ollama');
|
||||
}
|
||||
|
||||
|
||||
@ -461,7 +458,6 @@ module.exports = {
|
||||
setAutoUpdateSetting,
|
||||
// Model settings facade
|
||||
getModelSettings,
|
||||
validateAndSaveKey,
|
||||
clearApiKey,
|
||||
setSelectedModel,
|
||||
// Ollama facade
|
||||
|
@ -8,12 +8,11 @@ class ShortcutsService {
|
||||
constructor() {
|
||||
this.lastVisibleWindows = new Set(['header']);
|
||||
this.mouseEventsIgnored = false;
|
||||
this.movementManager = null;
|
||||
this.windowPool = null;
|
||||
this.allWindowVisibility = true;
|
||||
}
|
||||
|
||||
initialize(movementManager, windowPool) {
|
||||
this.movementManager = movementManager;
|
||||
initialize(windowPool) {
|
||||
this.windowPool = windowPool;
|
||||
internalBridge.on('reregister-shortcuts', () => {
|
||||
console.log('[ShortcutsService] Reregistering shortcuts due to header state change.');
|
||||
@ -22,6 +21,41 @@ class ShortcutsService {
|
||||
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 {
|
||||
@ -72,32 +106,6 @@ class ShortcutsService {
|
||||
return keybinds;
|
||||
}
|
||||
|
||||
async handleSaveShortcuts(newKeybinds) {
|
||||
try {
|
||||
await this.saveKeybinds(newKeybinds);
|
||||
const shortcutEditor = this.windowPool.get('shortcut-settings');
|
||||
if (shortcutEditor && !shortcutEditor.isDestroyed()) {
|
||||
shortcutEditor.close(); // This will trigger re-registration on 'closed' event in windowManager
|
||||
} else {
|
||||
// If editor wasn't open, re-register immediately
|
||||
await this.registerShortcuts();
|
||||
}
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error("Failed to save shortcuts:", error);
|
||||
// On failure, re-register old shortcuts to be safe
|
||||
await this.registerShortcuts();
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async handleRestoreDefaults() {
|
||||
const defaults = this.getDefaultKeybinds();
|
||||
await this.saveKeybinds(defaults);
|
||||
await this.registerShortcuts();
|
||||
return defaults;
|
||||
}
|
||||
|
||||
async saveKeybinds(newKeybinds) {
|
||||
const keybindsToSave = [];
|
||||
for (const action in newKeybinds) {
|
||||
@ -112,39 +120,23 @@ class ShortcutsService {
|
||||
console.log(`[Shortcuts] Saved keybinds.`);
|
||||
}
|
||||
|
||||
toggleAllWindowsVisibility(windowPool) {
|
||||
const header = windowPool.get('header');
|
||||
if (!header) return;
|
||||
async toggleAllWindowsVisibility() {
|
||||
const targetVisibility = !this.allWindowVisibility;
|
||||
internalBridge.emit('window:requestToggleAllWindowsVisibility', {
|
||||
targetVisibility: targetVisibility
|
||||
});
|
||||
|
||||
if (header.isVisible()) {
|
||||
this.lastVisibleWindows.clear();
|
||||
|
||||
windowPool.forEach((win, name) => {
|
||||
if (win && !win.isDestroyed() && win.isVisible()) {
|
||||
this.lastVisibleWindows.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
this.lastVisibleWindows.forEach(name => {
|
||||
if (name === 'header') return;
|
||||
const win = windowPool.get(name);
|
||||
if (win && !win.isDestroyed()) win.hide();
|
||||
});
|
||||
header.hide();
|
||||
|
||||
return;
|
||||
if (this.allWindowVisibility) {
|
||||
await this.registerShortcuts(true);
|
||||
} else {
|
||||
await this.registerShortcuts();
|
||||
}
|
||||
|
||||
this.lastVisibleWindows.forEach(name => {
|
||||
const win = windowPool.get(name);
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.show();
|
||||
}
|
||||
});
|
||||
this.allWindowVisibility = !this.allWindowVisibility;
|
||||
}
|
||||
|
||||
async registerShortcuts() {
|
||||
if (!this.movementManager || !this.windowPool) {
|
||||
async registerShortcuts(registerOnlyToggleVisibility = false) {
|
||||
if (!this.windowPool) {
|
||||
console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
|
||||
return;
|
||||
}
|
||||
@ -168,6 +160,14 @@ class ShortcutsService {
|
||||
|
||||
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';
|
||||
@ -177,7 +177,7 @@ class ShortcutsService {
|
||||
if (displays.length > 1) {
|
||||
displays.forEach((display, index) => {
|
||||
const key = `${modifier}+Shift+${index + 1}`;
|
||||
globalShortcut.register(key, () => this.movementManager.moveToDisplay(display.id));
|
||||
globalShortcut.register(key, () => internalBridge.emit('window:moveToDisplay', { displayId: display.id }));
|
||||
});
|
||||
}
|
||||
|
||||
@ -188,14 +188,14 @@ class ShortcutsService {
|
||||
];
|
||||
edgeDirections.forEach(({ key, direction }) => {
|
||||
globalShortcut.register(key, () => {
|
||||
if (header && header.isVisible()) this.movementManager.moveToEdge(direction);
|
||||
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(this.windowPool));
|
||||
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
|
||||
}
|
||||
console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.');
|
||||
return;
|
||||
@ -208,10 +208,10 @@ class ShortcutsService {
|
||||
let callback;
|
||||
switch(action) {
|
||||
case 'toggleVisibility':
|
||||
callback = () => this.toggleAllWindowsVisibility(this.windowPool);
|
||||
callback = () => this.toggleAllWindowsVisibility();
|
||||
break;
|
||||
case 'nextStep':
|
||||
callback = () => askService.toggleAskButton();
|
||||
callback = () => askService.toggleAskButton(true);
|
||||
break;
|
||||
case 'scrollUp':
|
||||
callback = () => {
|
||||
@ -230,16 +230,16 @@ class ShortcutsService {
|
||||
};
|
||||
break;
|
||||
case 'moveUp':
|
||||
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('up'); };
|
||||
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'up' }); };
|
||||
break;
|
||||
case 'moveDown':
|
||||
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('down'); };
|
||||
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'down' }); };
|
||||
break;
|
||||
case 'moveLeft':
|
||||
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('left'); };
|
||||
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'left' }); };
|
||||
break;
|
||||
case 'moveRight':
|
||||
callback = () => { if (header && header.isVisible()) this.movementManager.moveStep('right'); };
|
||||
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'right' }); };
|
||||
break;
|
||||
case 'toggleClickThrough':
|
||||
callback = () => {
|
||||
@ -282,4 +282,7 @@ class ShortcutsService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ShortcutsService();
|
||||
|
||||
const shortcutsService = new ShortcutsService();
|
||||
|
||||
module.exports = shortcutsService;
|
83
src/index.js
83
src/index.js
@ -532,7 +532,6 @@ async function handleFirebaseAuthCallback(params) {
|
||||
};
|
||||
|
||||
// 1. Sync user data to local DB
|
||||
userRepository.setAuthService(authService);
|
||||
userRepository.findOrCreate(firebaseUser);
|
||||
console.log('[Auth] User data synced with local DB.');
|
||||
|
||||
@ -687,75 +686,43 @@ async function startWebStack() {
|
||||
|
||||
console.log(`✅ API server started on http://localhost:${apiPort}`);
|
||||
|
||||
console.log(`🚀 All services ready:`);
|
||||
console.log(` Frontend: http://localhost:${frontendPort}`);
|
||||
console.log(` API: http://localhost:${apiPort}`);
|
||||
console.log(`🚀 All services ready:
|
||||
Frontend: http://localhost:${frontendPort}
|
||||
API: http://localhost:${apiPort}`);
|
||||
|
||||
return frontendPort;
|
||||
}
|
||||
|
||||
// Auto-update initialization
|
||||
async function initAutoUpdater() {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('Development environment, skipping auto-updater.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const autoUpdateEnabled = await settingsService.getAutoUpdateSetting();
|
||||
if (!autoUpdateEnabled) {
|
||||
console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings');
|
||||
return;
|
||||
}
|
||||
// Skip auto-updater in development mode
|
||||
if (!app.isPackaged) {
|
||||
console.log('[AutoUpdater] Skipped in development (app is not packaged)');
|
||||
return;
|
||||
}
|
||||
|
||||
autoUpdater.setFeedURL({
|
||||
provider: 'github',
|
||||
owner: 'pickle-com',
|
||||
repo: 'glass',
|
||||
await autoUpdater.checkForUpdates();
|
||||
autoUpdater.on('update-available', () => {
|
||||
console.log('Update available!');
|
||||
autoUpdater.downloadUpdate();
|
||||
});
|
||||
|
||||
// Immediately check for updates & notify
|
||||
autoUpdater.checkForUpdatesAndNotify()
|
||||
.catch(err => {
|
||||
console.error('[AutoUpdater] Error checking for updates:', err);
|
||||
});
|
||||
|
||||
autoUpdater.on('checking-for-update', () => {
|
||||
console.log('[AutoUpdater] Checking for updates…');
|
||||
});
|
||||
|
||||
autoUpdater.on('update-available', (info) => {
|
||||
console.log('[AutoUpdater] Update available:', info.version);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-not-available', () => {
|
||||
console.log('[AutoUpdater] Application is up-to-date');
|
||||
});
|
||||
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('[AutoUpdater] Error while updating:', err);
|
||||
});
|
||||
|
||||
autoUpdater.on('update-downloaded', (info) => {
|
||||
console.log(`[AutoUpdater] Update downloaded: ${info.version}`);
|
||||
|
||||
const dialogOpts = {
|
||||
autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => {
|
||||
console.log('Update downloaded:', releaseNotes, releaseName, date, url);
|
||||
dialog.showMessageBox({
|
||||
type: 'info',
|
||||
buttons: ['Install now', 'Install on next launch'],
|
||||
title: 'Update Available',
|
||||
message: 'A new version of Glass is ready to be installed.',
|
||||
defaultId: 0,
|
||||
cancelId: 1
|
||||
};
|
||||
|
||||
dialog.showMessageBox(dialogOpts).then((returnValue) => {
|
||||
// returnValue.response 0 is for 'Install Now'
|
||||
if (returnValue.response === 0) {
|
||||
title: 'Application Update',
|
||||
message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`,
|
||||
buttons: ['Restart', 'Later']
|
||||
}).then(response => {
|
||||
if (response.response === 0) {
|
||||
autoUpdater.quitAndInstall();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[AutoUpdater] Failed to initialise:', e);
|
||||
autoUpdater.on('error', (err) => {
|
||||
console.error('Error in auto-updater:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error initializing auto-updater:', err);
|
||||
}
|
||||
}
|
@ -19,6 +19,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
// App Control
|
||||
quitApplication: () => ipcRenderer.invoke('quit-application'),
|
||||
openExternal: (url) => ipcRenderer.invoke('open-external', url),
|
||||
|
||||
// User state listener (used by multiple components)
|
||||
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
|
||||
@ -30,11 +31,20 @@ contextBridge.exposeInMainWorld('api', {
|
||||
apiKeyHeader: {
|
||||
// Model & Provider Management
|
||||
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
|
||||
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
|
||||
// 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('ollama:install'),
|
||||
startOllamaService: () => ipcRenderer.invoke('ollama:start-service'),
|
||||
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),
|
||||
@ -46,21 +56,25 @@ contextBridge.exposeInMainWorld('api', {
|
||||
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
|
||||
|
||||
// Listeners
|
||||
onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback),
|
||||
removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback),
|
||||
onceOllamaInstallComplete: (callback) => ipcRenderer.once('ollama:install-complete', callback),
|
||||
removeOnceOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback),
|
||||
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
|
||||
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback),
|
||||
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
|
||||
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
|
||||
// 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: () => {
|
||||
ipcRenderer.removeAllListeners('whisper:download-progress');
|
||||
ipcRenderer.removeAllListeners('ollama:install-progress');
|
||||
ipcRenderer.removeAllListeners('ollama:pull-progress');
|
||||
ipcRenderer.removeAllListeners('ollama:install-complete');
|
||||
// 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');
|
||||
}
|
||||
},
|
||||
|
||||
@ -68,6 +82,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
headerController: {
|
||||
// State Management
|
||||
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
|
||||
reInitializeModelState: () => ipcRenderer.invoke('model:re-initialize-state'),
|
||||
|
||||
// Window Management
|
||||
resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
|
||||
@ -82,7 +97,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
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)
|
||||
removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback),
|
||||
},
|
||||
|
||||
// src/ui/app/MainHeader.js
|
||||
@ -94,11 +109,14 @@ contextBridge.exposeInMainWorld('api', {
|
||||
|
||||
// Settings Window Management
|
||||
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
|
||||
showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds),
|
||||
showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
|
||||
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
|
||||
|
||||
// Generic invoke (for dynamic channel names)
|
||||
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
|
||||
// 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),
|
||||
@ -113,7 +131,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
|
||||
requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
|
||||
openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
|
||||
markPermissionsCompleted: () => ipcRenderer.invoke('mark-permissions-completed')
|
||||
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
|
||||
@ -128,7 +148,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
askView: {
|
||||
// Window Management
|
||||
closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
|
||||
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
|
||||
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
|
||||
|
||||
// Message Handling
|
||||
sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
|
||||
@ -153,7 +173,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// src/ui/listen/ListenView.js
|
||||
listenView: {
|
||||
// Window Management
|
||||
adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height),
|
||||
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
|
||||
|
||||
// Listeners
|
||||
onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
|
||||
@ -212,8 +232,8 @@ contextBridge.exposeInMainWorld('api', {
|
||||
setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
|
||||
getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
|
||||
toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
|
||||
getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'),
|
||||
openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'),
|
||||
getCurrentShortcuts: () => ipcRenderer.invoke('settings:getCurrentShortcuts'),
|
||||
openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'),
|
||||
|
||||
// Window Management
|
||||
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
|
||||
@ -235,40 +255,30 @@ contextBridge.exposeInMainWorld('api', {
|
||||
removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
|
||||
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
|
||||
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
|
||||
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback),
|
||||
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback),
|
||||
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback),
|
||||
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback)
|
||||
// 통합 LocalAI 이벤트 사용
|
||||
onLocalAIInstallProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
|
||||
removeOnLocalAIInstallProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
|
||||
onLocalAIInstallationComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
|
||||
removeOnLocalAIInstallationComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback)
|
||||
},
|
||||
|
||||
// src/ui/settings/ShortCutSettingsView.js
|
||||
shortcutSettingsView: {
|
||||
// Shortcut Management
|
||||
saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts),
|
||||
getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'),
|
||||
closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'),
|
||||
saveShortcuts: (shortcuts) => ipcRenderer.invoke('shortcut:saveShortcuts', shortcuts),
|
||||
getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'),
|
||||
closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'),
|
||||
|
||||
// Listeners
|
||||
onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback),
|
||||
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback)
|
||||
onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback),
|
||||
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback)
|
||||
},
|
||||
|
||||
// src/ui/app/content.html inline scripts
|
||||
content: {
|
||||
// Animation Management
|
||||
sendAnimationFinished: () => ipcRenderer.send('animation-finished'),
|
||||
|
||||
// Listeners
|
||||
onWindowShowAnimation: (callback) => ipcRenderer.on('window-show-animation', callback),
|
||||
removeOnWindowShowAnimation: (callback) => ipcRenderer.removeListener('window-show-animation', callback),
|
||||
onWindowHideAnimation: (callback) => ipcRenderer.on('window-hide-animation', callback),
|
||||
removeOnWindowHideAnimation: (callback) => ipcRenderer.removeListener('window-hide-animation', callback),
|
||||
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
|
||||
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),
|
||||
onListenWindowMoveToCenter: (callback) => ipcRenderer.on('listen-window-move-to-center', callback),
|
||||
removeOnListenWindowMoveToCenter: (callback) => ipcRenderer.removeListener('listen-window-move-to-center', callback),
|
||||
onListenWindowMoveToLeft: (callback) => ipcRenderer.on('listen-window-move-to-left', callback),
|
||||
removeOnListenWindowMoveToLeft: (callback) => ipcRenderer.removeListener('listen-window-move-to-left', callback)
|
||||
},
|
||||
|
||||
// src/ui/listen/audioCore/listenCapture.js
|
||||
@ -280,7 +290,7 @@ contextBridge.exposeInMainWorld('api', {
|
||||
stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
|
||||
|
||||
// Session Management
|
||||
isSessionActive: () => ipcRenderer.invoke('is-session-active'),
|
||||
isSessionActive: () => ipcRenderer.invoke('listen:isSessionActive'),
|
||||
|
||||
// Listeners
|
||||
onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
|
||||
|
@ -256,6 +256,7 @@ export class ApiKeyHeader extends LitElement {
|
||||
.footer-link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
.error-message,
|
||||
.success-message {
|
||||
@ -1091,6 +1092,9 @@ export class ApiKeyHeader extends LitElement {
|
||||
this.requestUpdate();
|
||||
|
||||
const progressHandler = (event, data) => {
|
||||
// 통합 LocalAI 이벤트에서 Ollama 진행률만 처리
|
||||
if (data.service !== 'ollama') return;
|
||||
|
||||
let baseProgress = 0;
|
||||
let stageTotal = 0;
|
||||
|
||||
@ -1136,17 +1140,21 @@ export class ApiKeyHeader extends LitElement {
|
||||
}
|
||||
}, 15000); // 15 second timeout
|
||||
|
||||
const completionHandler = async (event, result) => {
|
||||
const completionHandler = async (event, data) => {
|
||||
// 통합 LocalAI 이벤트에서 Ollama 완료만 처리
|
||||
if (data.service !== 'ollama') return;
|
||||
if (operationCompleted) return;
|
||||
operationCompleted = true;
|
||||
clearTimeout(completionTimeout);
|
||||
|
||||
window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler);
|
||||
await this._handleOllamaSetupCompletion(result.success, result.error);
|
||||
window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
|
||||
// installation-complete 이벤트는 성공을 의미
|
||||
await this._handleOllamaSetupCompletion(true);
|
||||
};
|
||||
|
||||
window.api.apiKeyHeader.onceOllamaInstallComplete(completionHandler);
|
||||
window.api.apiKeyHeader.onOllamaInstallProgress(progressHandler);
|
||||
// 통합 LocalAI 이벤트 사용
|
||||
window.api.apiKeyHeader.onLocalAIComplete(completionHandler);
|
||||
window.api.apiKeyHeader.onLocalAIProgress(progressHandler);
|
||||
|
||||
try {
|
||||
let result;
|
||||
@ -1172,8 +1180,8 @@ export class ApiKeyHeader extends LitElement {
|
||||
operationCompleted = true;
|
||||
clearTimeout(completionTimeout);
|
||||
console.error('[ApiKeyHeader] Ollama setup failed:', error);
|
||||
window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler);
|
||||
window.api.apiKeyHeader.removeOnceOllamaInstallComplete(completionHandler);
|
||||
window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
|
||||
window.api.apiKeyHeader.removeOnLocalAIComplete(completionHandler);
|
||||
await this._handleOllamaSetupCompletion(false, error.message);
|
||||
}
|
||||
}
|
||||
@ -1303,7 +1311,7 @@ export class ApiKeyHeader extends LitElement {
|
||||
|
||||
// Create robust progress handler with timeout protection
|
||||
progressHandler = (event, data) => {
|
||||
if (data.model === modelName && !this._isOperationCancelled(modelName)) {
|
||||
if (data.service === 'ollama' && data.model === modelName && !this._isOperationCancelled(modelName)) {
|
||||
const progress = Math.round(Math.max(0, Math.min(100, data.progress || 0)));
|
||||
|
||||
if (progress !== this.installProgress) {
|
||||
@ -1314,8 +1322,8 @@ export class ApiKeyHeader extends LitElement {
|
||||
}
|
||||
};
|
||||
|
||||
// Set up progress tracking
|
||||
window.api.apiKeyHeader.onOllamaPullProgress(progressHandler);
|
||||
// Set up progress tracking - 통합 LocalAI 이벤트 사용
|
||||
window.api.apiKeyHeader.onLocalAIProgress(progressHandler);
|
||||
|
||||
// Execute the model pull with timeout
|
||||
const installPromise = window.api.apiKeyHeader.pullOllamaModel(modelName);
|
||||
@ -1345,7 +1353,7 @@ export class ApiKeyHeader extends LitElement {
|
||||
} finally {
|
||||
// Comprehensive cleanup
|
||||
if (progressHandler) {
|
||||
window.api.apiKeyHeader.removeOnOllamaPullProgress(progressHandler);
|
||||
window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
|
||||
}
|
||||
|
||||
this.installingModel = null;
|
||||
@ -1375,17 +1383,17 @@ export class ApiKeyHeader extends LitElement {
|
||||
let progressHandler = null;
|
||||
|
||||
try {
|
||||
// Set up robust progress listener
|
||||
progressHandler = (event, { modelId: id, progress }) => {
|
||||
if (id === modelId) {
|
||||
const cleanProgress = Math.round(Math.max(0, Math.min(100, progress || 0)));
|
||||
// Set up robust progress listener - 통합 LocalAI 이벤트 사용
|
||||
progressHandler = (event, data) => {
|
||||
if (data.service === 'whisper' && data.model === modelId) {
|
||||
const cleanProgress = Math.round(Math.max(0, Math.min(100, data.progress || 0)));
|
||||
this.whisperInstallingModels = { ...this.whisperInstallingModels, [modelId]: cleanProgress };
|
||||
console.log(`[ApiKeyHeader] Whisper download progress: ${cleanProgress}% for ${modelId}`);
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
window.api.apiKeyHeader.onWhisperDownloadProgress(progressHandler);
|
||||
window.api.apiKeyHeader.onLocalAIProgress(progressHandler);
|
||||
|
||||
// Start download with timeout protection
|
||||
const downloadPromise = window.api.apiKeyHeader.downloadWhisperModel(modelId);
|
||||
@ -1412,7 +1420,7 @@ export class ApiKeyHeader extends LitElement {
|
||||
} finally {
|
||||
// Cleanup
|
||||
if (progressHandler) {
|
||||
window.api.apiKeyHeader.removeOnWhisperDownloadProgress(progressHandler);
|
||||
window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
|
||||
}
|
||||
delete this.whisperInstallingModels[modelId];
|
||||
this.requestUpdate();
|
||||
@ -1904,8 +1912,9 @@ export class ApiKeyHeader extends LitElement {
|
||||
}
|
||||
|
||||
openPrivacyPolicy() {
|
||||
if (window.require) {
|
||||
window.require('electron').shell.openExternal('https://pickleglass.com/privacy');
|
||||
console.log('🔊 openPrivacyPolicy ApiKeyHeader');
|
||||
if (window.api?.common) {
|
||||
window.api.common.openExternal('https://pickle.com/privacy-policy');
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -48,7 +48,16 @@ class HeaderTransitionManager {
|
||||
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
|
||||
} else if (type === 'permission') {
|
||||
this.permissionHeader = document.createElement('permission-setup');
|
||||
this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
|
||||
this.permissionHeader.addEventListener('request-resize', e => {
|
||||
this._resizeForPermissionHeader(e.detail.height);
|
||||
});
|
||||
this.permissionHeader.continueCallback = async () => {
|
||||
if (window.api && window.api.headerController) {
|
||||
console.log('[HeaderController] Re-initializing model state after permission grant...');
|
||||
await window.api.headerController.reInitializeModelState();
|
||||
}
|
||||
this.transitionToMainHeader();
|
||||
};
|
||||
this.headerContainer.appendChild(this.permissionHeader);
|
||||
} else {
|
||||
this.mainHeader = document.createElement('main-header');
|
||||
@ -121,19 +130,15 @@ class HeaderTransitionManager {
|
||||
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
|
||||
|
||||
if (isConfigured) {
|
||||
const { isLoggedIn } = userState;
|
||||
if (isLoggedIn) {
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToMainHeader();
|
||||
} else {
|
||||
this.transitionToPermissionHeader();
|
||||
}
|
||||
} else {
|
||||
// If providers are configured, always check permissions regardless of login state.
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
this.transitionToMainHeader();
|
||||
} else {
|
||||
this.transitionToPermissionHeader();
|
||||
}
|
||||
} else {
|
||||
// 프로바이더가 설정되지 않았으면 WelcomeHeader 먼저 표시
|
||||
// If no providers are configured, show the welcome header to prompt for setup.
|
||||
await this._resizeForWelcome();
|
||||
this.ensureHeader('welcome');
|
||||
}
|
||||
@ -196,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');
|
||||
}
|
||||
|
||||
@ -221,9 +238,10 @@ class HeaderTransitionManager {
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForPermissionHeader() {
|
||||
async _resizeForPermissionHeader(height) {
|
||||
if (!window.api) return;
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 })
|
||||
const finalHeight = height || 220;
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 285, height: finalHeight })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
|
@ -2,7 +2,6 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class MainHeader extends LitElement {
|
||||
static properties = {
|
||||
// isSessionActive: { type: Boolean, state: true },
|
||||
isTogglingSession: { type: Boolean, state: true },
|
||||
shortcuts: { type: Object, state: true },
|
||||
listenSessionStatus: { type: String, state: true },
|
||||
@ -515,30 +514,12 @@ export class MainHeader extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
invoke(channel, ...args) {
|
||||
if (this.wasJustDragged) return;
|
||||
if (window.api) {
|
||||
window.api.mainHeader.invoke(channel, ...args);
|
||||
}
|
||||
// return Promise.resolve();
|
||||
}
|
||||
|
||||
showSettingsWindow(element) {
|
||||
if (this.wasJustDragged) return;
|
||||
if (window.api) {
|
||||
console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`);
|
||||
window.api.mainHeader.showSettingsWindow();
|
||||
|
||||
window.api.mainHeader.cancelHideSettingsWindow();
|
||||
|
||||
if (element) {
|
||||
const { left, top, width, height } = element.getBoundingClientRect();
|
||||
window.api.mainHeader.showSettingsWindow({
|
||||
x: left,
|
||||
y: top,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -559,9 +540,10 @@ export class MainHeader extends LitElement {
|
||||
this.isTogglingSession = true;
|
||||
|
||||
try {
|
||||
const channel = 'listen:changeSession';
|
||||
const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
|
||||
await this.invoke(channel, listenButtonText);
|
||||
if (window.api) {
|
||||
await window.api.mainHeader.sendListenButtonClick(listenButtonText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IPC invoke for session change failed:', error);
|
||||
this.isTogglingSession = false;
|
||||
@ -572,13 +554,26 @@ export class MainHeader extends LitElement {
|
||||
if (this.wasJustDragged) return;
|
||||
|
||||
try {
|
||||
const channel = 'ask:toggleAskButton';
|
||||
await this.invoke(channel);
|
||||
if (window.api) {
|
||||
await window.api.mainHeader.sendAskButtonClick();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IPC invoke for ask button failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async _handleToggleAllWindowsVisibility() {
|
||||
if (this.wasJustDragged) return;
|
||||
|
||||
try {
|
||||
if (window.api) {
|
||||
await window.api.mainHeader.sendToggleAllWindowsVisibility();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('IPC invoke for all windows visibility button failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
renderShortcut(accelerator) {
|
||||
if (!accelerator) return html``;
|
||||
@ -656,7 +651,7 @@ export class MainHeader extends LitElement {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" @click=${() => this.invoke('toggle-all-windows-visibility')}>
|
||||
<div class="header-actions" @click=${() => this._handleToggleAllWindowsVisibility()}>
|
||||
<div class="action-text">
|
||||
<div class="action-text-content">Show/Hide</div>
|
||||
</div>
|
||||
|
@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement {
|
||||
.container {
|
||||
-webkit-app-region: drag;
|
||||
width: 285px;
|
||||
height: 220px;
|
||||
/* height is now set dynamically */
|
||||
padding: 18px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 16px;
|
||||
@ -103,6 +103,12 @@ export class PermissionHeader extends LitElement {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.form-content.all-granted {
|
||||
flex-grow: 1;
|
||||
justify-content: center;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
@ -258,24 +264,60 @@ export class PermissionHeader extends LitElement {
|
||||
static properties = {
|
||||
microphoneGranted: { type: String },
|
||||
screenGranted: { type: String },
|
||||
keychainGranted: { type: String },
|
||||
isChecking: { type: String },
|
||||
continueCallback: { type: Function }
|
||||
continueCallback: { type: Function },
|
||||
userMode: { type: String }, // 'local' or 'firebase'
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.microphoneGranted = 'unknown';
|
||||
this.screenGranted = 'unknown';
|
||||
this.keychainGranted = 'unknown';
|
||||
this.isChecking = false;
|
||||
this.continueCallback = null;
|
||||
this.userMode = 'local'; // Default to local
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has('userMode')) {
|
||||
const newHeight = this.userMode === 'firebase' ? 280 : 220;
|
||||
console.log(`[PermissionHeader] User mode changed to ${this.userMode}, requesting resize to ${newHeight}px`);
|
||||
this.dispatchEvent(new CustomEvent('request-resize', {
|
||||
detail: { height: newHeight },
|
||||
bubbles: true,
|
||||
composed: true
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (window.api) {
|
||||
try {
|
||||
const userState = await window.api.common.getCurrentUser();
|
||||
this.userMode = userState.mode;
|
||||
} catch (e) {
|
||||
console.error('[PermissionHeader] Failed to get user state', e);
|
||||
this.userMode = 'local'; // Fallback to local
|
||||
}
|
||||
}
|
||||
|
||||
await this.checkPermissions();
|
||||
|
||||
// Set up periodic permission check
|
||||
this.permissionCheckInterval = setInterval(() => {
|
||||
this.permissionCheckInterval = setInterval(async () => {
|
||||
if (window.api) {
|
||||
try {
|
||||
const userState = await window.api.common.getCurrentUser();
|
||||
this.userMode = userState.mode;
|
||||
} catch (e) {
|
||||
this.userMode = 'local';
|
||||
}
|
||||
}
|
||||
this.checkPermissions();
|
||||
}, 1000);
|
||||
}
|
||||
@ -298,19 +340,25 @@ export class PermissionHeader extends LitElement {
|
||||
|
||||
const prevMic = this.microphoneGranted;
|
||||
const prevScreen = this.screenGranted;
|
||||
const prevKeychain = this.keychainGranted;
|
||||
|
||||
this.microphoneGranted = permissions.microphone;
|
||||
this.screenGranted = permissions.screen;
|
||||
this.keychainGranted = permissions.keychain;
|
||||
|
||||
// if permissions changed == UI update
|
||||
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
|
||||
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted || prevKeychain !== this.keychainGranted) {
|
||||
console.log('[PermissionHeader] Permission status changed, updating UI');
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
const isKeychainRequired = this.userMode === 'firebase';
|
||||
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
|
||||
|
||||
// if all permissions granted == automatically continue
|
||||
if (this.microphoneGranted === 'granted' &&
|
||||
this.screenGranted === 'granted' &&
|
||||
keychainOk &&
|
||||
this.continueCallback) {
|
||||
console.log('[PermissionHeader] All permissions granted, proceeding automatically');
|
||||
setTimeout(() => this.handleContinue(), 500);
|
||||
@ -381,17 +429,39 @@ export class PermissionHeader extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
async handleKeychainClick() {
|
||||
if (!window.api || this.keychainGranted === 'granted') return;
|
||||
|
||||
console.log('[PermissionHeader] Requesting keychain permission...');
|
||||
|
||||
try {
|
||||
// Trigger initializeKey to prompt for keychain access
|
||||
// Assuming encryptionService is accessible or via API
|
||||
await window.api.permissionHeader.initializeEncryptionKey(); // New IPC handler needed
|
||||
|
||||
// After success, update status
|
||||
this.keychainGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error('[PermissionHeader] Error requesting keychain permission:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleContinue() {
|
||||
const isKeychainRequired = this.userMode === 'firebase';
|
||||
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
|
||||
|
||||
if (this.continueCallback &&
|
||||
this.microphoneGranted === 'granted' &&
|
||||
this.screenGranted === 'granted') {
|
||||
this.screenGranted === 'granted' &&
|
||||
keychainOk) {
|
||||
// Mark permissions as completed
|
||||
if (window.api) {
|
||||
if (window.api && isKeychainRequired) {
|
||||
try {
|
||||
await window.api.permissionHeader.markPermissionsCompleted();
|
||||
console.log('[PermissionHeader] Marked permissions as completed');
|
||||
await window.api.permissionHeader.markKeychainCompleted();
|
||||
console.log('[PermissionHeader] Marked keychain as completed');
|
||||
} catch (error) {
|
||||
console.error('[PermissionHeader] Error marking permissions as completed:', error);
|
||||
console.error('[PermissionHeader] Error marking keychain as completed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
@ -407,10 +477,13 @@ export class PermissionHeader extends LitElement {
|
||||
}
|
||||
|
||||
render() {
|
||||
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
|
||||
const isKeychainRequired = this.userMode === 'firebase';
|
||||
const containerHeight = isKeychainRequired ? 280 : 220;
|
||||
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
|
||||
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && keychainOk;
|
||||
|
||||
return html`
|
||||
<div class="container">
|
||||
<div class="container" style="height: ${containerHeight}px">
|
||||
<button class="close-button" @click=${this.handleClose} title="Close application">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
|
||||
@ -418,65 +491,92 @@ export class PermissionHeader extends LitElement {
|
||||
</button>
|
||||
<h1 class="title">Permission Setup Required</h1>
|
||||
|
||||
<div class="form-content">
|
||||
<div class="subtitle">Grant access to microphone and screen recording to continue</div>
|
||||
<div class="form-content ${allGranted ? 'all-granted' : ''}">
|
||||
${!allGranted ? html`
|
||||
<div class="subtitle">Grant access to microphone, screen recording${isKeychainRequired ? ' and keychain' : ''} to continue</div>
|
||||
|
||||
<div class="permission-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 class="permission-status">
|
||||
<div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.microphoneGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Microphone ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Microphone</span>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.screenGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen Recording</span>
|
||||
`}
|
||||
</div>
|
||||
|
||||
${isKeychainRequired ? html`
|
||||
<div class="permission-item ${this.keychainGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.keychainGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Data Encryption ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.744 5.668l-1.649 1.652c-.63.63-1.706.19-1.706-.742V12.18a.75.75 0 00-1.5 0v2.696c0 .932-1.075 1.372-1.706.742l-1.649-1.652A6 6 0 112 8zm-4 0a.75.75 0 00.75-.75A3.75 3.75 0 018.25 4a.75.75 0 000 1.5 2.25 2.25 0 012.25 2.25.75.75 0 00.75.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Data Encryption</span>
|
||||
`}
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.screenGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen Recording</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.microphoneGranted !== 'granted' ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleMicrophoneClick}
|
||||
?disabled=${this.microphoneGranted === 'granted'}
|
||||
>
|
||||
Grant Microphone Access
|
||||
${this.microphoneGranted === 'granted' ? 'Microphone Access Granted' : 'Grant Microphone Access'}
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${this.screenGranted !== 'granted' ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleScreenClick}
|
||||
?disabled=${this.screenGranted === 'granted'}
|
||||
>
|
||||
Grant Screen Recording Access
|
||||
${this.screenGranted === 'granted' ? 'Screen Recording Granted' : 'Grant Screen Recording Access'}
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${allGranted ? html`
|
||||
${isKeychainRequired ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleKeychainClick}
|
||||
?disabled=${this.keychainGranted === 'granted'}
|
||||
>
|
||||
${this.keychainGranted === 'granted' ? 'Encryption Enabled' : 'Enable Encryption'}
|
||||
</button>
|
||||
<div class="subtitle" style="visibility: ${this.keychainGranted === 'granted' ? 'hidden' : 'visible'}">
|
||||
Stores the key to encrypt your data. Press "<b>Always Allow</b>" to continue.
|
||||
</div>
|
||||
` : ''}
|
||||
` : html`
|
||||
<button
|
||||
class="continue-button"
|
||||
@click=${this.handleContinue}
|
||||
>
|
||||
Continue to Pickle Glass
|
||||
</button>
|
||||
` : ''}
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
@ -156,6 +156,7 @@ export class WelcomeHeader extends LitElement {
|
||||
.footer-link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
-webkit-app-region: no-drag;
|
||||
}
|
||||
`;
|
||||
|
||||
@ -177,8 +178,8 @@ export class WelcomeHeader extends LitElement {
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('quit-application');
|
||||
if (window.api?.common) {
|
||||
window.api.common.quitApplication();
|
||||
}
|
||||
}
|
||||
|
||||
@ -225,8 +226,9 @@ export class WelcomeHeader extends LitElement {
|
||||
}
|
||||
|
||||
openPrivacyPolicy() {
|
||||
if (window.require) {
|
||||
window.require('electron').shell.openExternal('https://pickleglass.com/privacy');
|
||||
console.log('🔊 openPrivacyPolicy WelcomeHeader');
|
||||
if (window.api?.common) {
|
||||
window.api.common.openExternal('https://pickle.com/privacy-policy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -98,133 +98,6 @@
|
||||
contain: layout style paint;
|
||||
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
|
||||
}
|
||||
|
||||
.window-sliding-down {
|
||||
animation: slideDownFromHeader 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.window-sliding-up {
|
||||
animation: slideUpToHeader 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.window-hidden {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
|
||||
pointer-events: none;
|
||||
will-change: auto;
|
||||
contain: layout style paint;
|
||||
}
|
||||
|
||||
.listen-window-moving {
|
||||
transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.listen-window-center {
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
.listen-window-left {
|
||||
transform: translate3d(-110px, 0, 0);
|
||||
}
|
||||
|
||||
@keyframes slideDownFromHeader {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
|
||||
}
|
||||
25% {
|
||||
opacity: 0.4;
|
||||
transform: translate3d(0, -10px, 0) scale3d(0.98, 0.98, 1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.7;
|
||||
transform: translate3d(0, -3px, 0) scale3d(1.01, 1.01, 1);
|
||||
}
|
||||
75% {
|
||||
opacity: 0.9;
|
||||
transform: translate3d(0, -0.5px, 0) scale3d(1.005, 1.005, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.settings-window-show {
|
||||
animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
transform-origin: 85% 0%;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.settings-window-hide {
|
||||
animation: settingsCollapseToButton 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
transform-origin: 85% 0%;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
@keyframes settingsPopFromButton {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
40% {
|
||||
opacity: 0.8;
|
||||
transform: translate3d(0, -2px, 0) scale3d(1.05, 1.05, 1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.95;
|
||||
transform: translate3d(0, 0, 0) scale3d(1.02, 1.02, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes settingsCollapseToButton {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.8;
|
||||
transform: translate3d(0, -1px, 0) scale3d(0.9, 0.9, 1);
|
||||
}
|
||||
70% {
|
||||
opacity: 0.3;
|
||||
transform: translate3d(0, -5px, 0) scale3d(0.7, 0.7, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slideUpToHeader {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
|
||||
}
|
||||
30% {
|
||||
opacity: 0.6;
|
||||
transform: translate3d(0, -6px, 0) scale3d(0.98, 0.98, 1);
|
||||
}
|
||||
65% {
|
||||
opacity: 0.2;
|
||||
transform: translate3d(0, -14px, 0) scale3d(0.95, 0.95, 1);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -18px, 0) scale3d(0.93, 0.93, 1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -238,64 +111,6 @@
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const app = document.getElementById('pickle-glass');
|
||||
|
||||
if (window.api) {
|
||||
// --- 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.content.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.content.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.content.onWindowHideAnimation(() => {
|
||||
console.log('Starting window hide animation');
|
||||
app.classList.remove('window-sliding-down', 'settings-window-show');
|
||||
app.classList.add('window-sliding-up');
|
||||
});
|
||||
|
||||
window.api.content.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.content.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.content.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>
|
||||
|
@ -1,4 +1,5 @@
|
||||
import { html, css, LitElement } from '../../ui/assets/lit-core-2.7.4.min.js';
|
||||
import { parser, parser_write, parser_end, default_renderer } from '../../ui/assets/smd.js';
|
||||
|
||||
export class AskView extends LitElement {
|
||||
static properties = {
|
||||
@ -502,6 +503,7 @@ export class AskView extends LitElement {
|
||||
padding: 0;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.text-input-container.no-response {
|
||||
@ -725,6 +727,10 @@ export class AskView extends LitElement {
|
||||
this.DOMPurify = null;
|
||||
this.isLibrariesLoaded = false;
|
||||
|
||||
// SMD.js streaming markdown parser
|
||||
this.smdParser = null;
|
||||
this.smdContainer = null;
|
||||
this.lastProcessedLength = 0;
|
||||
|
||||
this.handleSendText = this.handleSendText.bind(this);
|
||||
this.handleTextKeydown = this.handleTextKeydown.bind(this);
|
||||
@ -763,17 +769,19 @@ export class AskView extends LitElement {
|
||||
if (container) this.resizeObserver.observe(container);
|
||||
|
||||
this.handleQuestionFromAssistant = (event, question) => {
|
||||
console.log('📨 AskView: Received question from ListenView:', question);
|
||||
console.log('AskView: Received question from ListenView:', question);
|
||||
this.handleSendText(null, question);
|
||||
};
|
||||
|
||||
if (window.api) {
|
||||
window.api.askView.onShowTextInput(() => {
|
||||
console.log('📤 Show text input signal received');
|
||||
console.log('Show text input signal received');
|
||||
if (!this.showTextInput) {
|
||||
this.showTextInput = true;
|
||||
this.requestUpdate();
|
||||
}
|
||||
this.updateComplete.then(() => this.focusTextInput());
|
||||
} else {
|
||||
this.focusTextInput();
|
||||
}
|
||||
});
|
||||
|
||||
window.api.askView.onScrollResponseUp(() => this.handleScroll('up'));
|
||||
@ -781,11 +789,21 @@ export class AskView extends LitElement {
|
||||
window.api.askView.onAskStateUpdate((event, newState) => {
|
||||
this.currentResponse = newState.currentResponse;
|
||||
this.currentQuestion = newState.currentQuestion;
|
||||
this.isLoading = newState.isLoading;
|
||||
this.isStreaming = newState.isStreaming;
|
||||
this.isLoading = newState.isLoading;
|
||||
this.isStreaming = newState.isStreaming;
|
||||
|
||||
const wasHidden = !this.showTextInput;
|
||||
this.showTextInput = newState.showTextInput;
|
||||
});
|
||||
console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
|
||||
|
||||
if (newState.showTextInput) {
|
||||
if (wasHidden) {
|
||||
this.updateComplete.then(() => this.focusTextInput());
|
||||
} else {
|
||||
this.focusTextInput();
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('AskView: IPC 이벤트 리스너 등록 완료');
|
||||
}
|
||||
}
|
||||
|
||||
@ -878,7 +896,7 @@ export class AskView extends LitElement {
|
||||
}
|
||||
|
||||
handleCloseAskWindow() {
|
||||
this.clearResponseContent();
|
||||
// this.clearResponseContent();
|
||||
window.api.askView.closeAskWindow();
|
||||
}
|
||||
|
||||
@ -902,6 +920,9 @@ export class AskView extends LitElement {
|
||||
this.isStreaming = false;
|
||||
this.headerText = 'AI Response';
|
||||
this.showTextInput = true;
|
||||
this.lastProcessedLength = 0;
|
||||
this.smdParser = null;
|
||||
this.smdContainer = null;
|
||||
}
|
||||
|
||||
handleInputFocus() {
|
||||
@ -973,21 +994,88 @@ export class AskView extends LitElement {
|
||||
const responseContainer = this.shadowRoot.getElementById('responseContainer');
|
||||
if (!responseContainer) return;
|
||||
|
||||
// ✨ 로딩 상태를 먼저 확인
|
||||
// Check loading state
|
||||
if (this.isLoading) {
|
||||
responseContainer.innerHTML = `<div class="loading-dots">...</div>`;
|
||||
responseContainer.innerHTML = `
|
||||
<div class="loading-dots">
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
<div class="loading-dot"></div>
|
||||
</div>`;
|
||||
this.resetStreamingParser();
|
||||
return;
|
||||
}
|
||||
|
||||
// ✨ 응답이 없을 때의 처리
|
||||
// If there is no response, show empty state
|
||||
if (!this.currentResponse) {
|
||||
responseContainer.innerHTML = `<div class="empty-state">...</div>`;
|
||||
this.resetStreamingParser();
|
||||
return;
|
||||
}
|
||||
|
||||
let textToRender = this.fixIncompleteMarkdown(this.currentResponse);
|
||||
textToRender = this.fixIncompleteCodeBlocks(textToRender);
|
||||
// Set streaming markdown parser
|
||||
this.renderStreamingMarkdown(responseContainer);
|
||||
|
||||
// After updating content, recalculate window height
|
||||
this.adjustWindowHeightThrottled();
|
||||
}
|
||||
|
||||
resetStreamingParser() {
|
||||
this.smdParser = null;
|
||||
this.smdContainer = null;
|
||||
this.lastProcessedLength = 0;
|
||||
}
|
||||
|
||||
renderStreamingMarkdown(responseContainer) {
|
||||
try {
|
||||
// 파서가 없거나 컨테이너가 변경되었으면 새로 생성
|
||||
if (!this.smdParser || this.smdContainer !== responseContainer) {
|
||||
this.smdContainer = responseContainer;
|
||||
this.smdContainer.innerHTML = '';
|
||||
|
||||
// smd.js의 default_renderer 사용
|
||||
const renderer = default_renderer(this.smdContainer);
|
||||
this.smdParser = parser(renderer);
|
||||
this.lastProcessedLength = 0;
|
||||
}
|
||||
|
||||
// 새로운 텍스트만 처리 (스트리밍 최적화)
|
||||
const currentText = this.currentResponse;
|
||||
const newText = currentText.slice(this.lastProcessedLength);
|
||||
|
||||
if (newText.length > 0) {
|
||||
// 새로운 텍스트 청크를 파서에 전달
|
||||
parser_write(this.smdParser, newText);
|
||||
this.lastProcessedLength = currentText.length;
|
||||
}
|
||||
|
||||
// 스트리밍이 완료되면 파서 종료
|
||||
if (!this.isStreaming && !this.isLoading) {
|
||||
parser_end(this.smdParser);
|
||||
}
|
||||
|
||||
// 코드 하이라이팅 적용
|
||||
if (this.hljs) {
|
||||
responseContainer.querySelectorAll('pre code').forEach(block => {
|
||||
if (!block.hasAttribute('data-highlighted')) {
|
||||
this.hljs.highlightElement(block);
|
||||
block.setAttribute('data-highlighted', 'true');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 스크롤을 맨 아래로
|
||||
responseContainer.scrollTop = responseContainer.scrollHeight;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error rendering streaming markdown:', error);
|
||||
// 에러 발생 시 기본 텍스트 렌더링으로 폴백
|
||||
this.renderFallbackContent(responseContainer);
|
||||
}
|
||||
}
|
||||
|
||||
renderFallbackContent(responseContainer) {
|
||||
const textToRender = this.currentResponse || '';
|
||||
|
||||
if (this.isLibrariesLoaded && this.marked && this.DOMPurify) {
|
||||
try {
|
||||
@ -997,42 +1085,13 @@ export class AskView extends LitElement {
|
||||
// DOMPurify로 정제
|
||||
const cleanHtml = this.DOMPurify.sanitize(parsedHtml, {
|
||||
ALLOWED_TAGS: [
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'p',
|
||||
'br',
|
||||
'strong',
|
||||
'b',
|
||||
'em',
|
||||
'i',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'blockquote',
|
||||
'code',
|
||||
'pre',
|
||||
'a',
|
||||
'img',
|
||||
'table',
|
||||
'thead',
|
||||
'tbody',
|
||||
'tr',
|
||||
'th',
|
||||
'td',
|
||||
'hr',
|
||||
'sup',
|
||||
'sub',
|
||||
'del',
|
||||
'ins',
|
||||
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'b', 'em', 'i',
|
||||
'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'a', 'img', 'table', 'thead',
|
||||
'tbody', 'tr', 'th', 'td', 'hr', 'sup', 'sub', 'del', 'ins',
|
||||
],
|
||||
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'id', 'target', 'rel'],
|
||||
});
|
||||
|
||||
// HTML 적용
|
||||
responseContainer.innerHTML = cleanHtml;
|
||||
|
||||
// 코드 하이라이팅 적용
|
||||
@ -1041,12 +1100,8 @@ export class AskView extends LitElement {
|
||||
this.hljs.highlightElement(block);
|
||||
});
|
||||
}
|
||||
|
||||
// 스크롤을 맨 아래로
|
||||
responseContainer.scrollTop = responseContainer.scrollHeight;
|
||||
} catch (error) {
|
||||
console.error('Error rendering markdown:', error);
|
||||
// 에러 발생 시 일반 텍스트로 표시
|
||||
console.error('Error in fallback rendering:', error);
|
||||
responseContainer.textContent = textToRender;
|
||||
}
|
||||
} else {
|
||||
@ -1063,9 +1118,6 @@ export class AskView extends LitElement {
|
||||
|
||||
responseContainer.innerHTML = `<p>${basicHtml}</p>`;
|
||||
}
|
||||
|
||||
// 🚀 After updating content, recalculate window height
|
||||
this.adjustWindowHeightThrottled();
|
||||
}
|
||||
|
||||
|
||||
@ -1220,7 +1272,7 @@ export class AskView extends LitElement {
|
||||
async handleSendText(e, overridingText = '') {
|
||||
const textInput = this.shadowRoot?.getElementById('textInput');
|
||||
const text = (overridingText || textInput?.value || '').trim();
|
||||
if (!text) return;
|
||||
// if (!text) return;
|
||||
|
||||
textInput.value = '';
|
||||
|
||||
@ -1370,7 +1422,7 @@ export class AskView extends LitElement {
|
||||
|
||||
const targetHeight = Math.min(700, idealHeight);
|
||||
|
||||
window.api.askView.adjustWindowHeight(targetHeight);
|
||||
window.api.askView.adjustWindowHeight("ask", targetHeight);
|
||||
|
||||
}).catch(err => console.error('AskView adjustWindowHeight error:', err));
|
||||
}
|
||||
|
1665
src/ui/assets/smd.js
Normal file
1665
src/ui/assets/smd.js
Normal file
File diff suppressed because it is too large
Load Diff
@ -536,7 +536,7 @@ export class ListenView extends LitElement {
|
||||
`[Height Adjusted] Mode: ${this.viewMode}, TopBar: ${topBarHeight}px, Content: ${contentHeight}px, Ideal: ${idealHeight}px, Target: ${targetHeight}px`
|
||||
);
|
||||
|
||||
window.api.listenView.adjustWindowHeight(targetHeight);
|
||||
window.api.listenView.adjustWindowHeight('listen', targetHeight);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error in adjustWindowHeight:', error);
|
||||
|
@ -422,6 +422,12 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
|
||||
|
||||
try {
|
||||
if (isMacOS) {
|
||||
|
||||
const sessionActive = await window.api.listenCapture.isSessionActive();
|
||||
if (!sessionActive) {
|
||||
throw new Error('STT sessions not initialized - please wait for initialization to complete');
|
||||
}
|
||||
|
||||
// On macOS, use SystemAudioDump for audio and getDisplayMedia for screen
|
||||
console.log('Starting macOS capture with SystemAudioDump...');
|
||||
|
||||
@ -466,6 +472,12 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
|
||||
|
||||
console.log('macOS screen capture started - audio handled by SystemAudioDump');
|
||||
} else if (isLinux) {
|
||||
|
||||
const sessionActive = await window.api.listenCapture.isSessionActive();
|
||||
if (!sessionActive) {
|
||||
throw new Error('STT sessions not initialized - please wait for initialization to complete');
|
||||
}
|
||||
|
||||
// Linux - use display media for screen capture and getUserMedia for microphone
|
||||
mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
|
@ -575,31 +575,62 @@ export class SettingsView extends LitElement {
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
async loadLocalAIStatus() {
|
||||
try {
|
||||
// Load Ollama status
|
||||
const ollamaStatus = await window.api.settingsView.getOllamaStatus();
|
||||
if (ollamaStatus?.success) {
|
||||
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
|
||||
this.ollamaModels = ollamaStatus.models || [];
|
||||
}
|
||||
|
||||
// Load Whisper models status only if Whisper is enabled
|
||||
if (this.apiKeys?.whisper === 'local') {
|
||||
const whisperModelsResult = await window.api.settingsView.getWhisperInstalledModels();
|
||||
if (whisperModelsResult?.success) {
|
||||
const installedWhisperModels = whisperModelsResult.models;
|
||||
if (this.providerConfig?.whisper) {
|
||||
this.providerConfig.whisper.sttModels.forEach(m => {
|
||||
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
|
||||
if (installedInfo) {
|
||||
m.installed = installedInfo.installed;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger UI update
|
||||
this.requestUpdate();
|
||||
} catch (error) {
|
||||
console.error('Error loading LocalAI status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
//////// after_modelStateService ////////
|
||||
async loadInitialData() {
|
||||
if (!window.api) return;
|
||||
this.isLoading = true;
|
||||
try {
|
||||
const [userState, modelSettings, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = await Promise.all([
|
||||
// Load essential data first
|
||||
const [userState, modelSettings, presets, contentProtection, shortcuts] = await Promise.all([
|
||||
window.api.settingsView.getCurrentUser(),
|
||||
window.api.settingsView.getModelSettings(), // Facade call
|
||||
window.api.settingsView.getPresets(),
|
||||
window.api.settingsView.getContentProtectionStatus(),
|
||||
window.api.settingsView.getCurrentShortcuts(),
|
||||
window.api.settingsView.getOllamaStatus(),
|
||||
window.api.settingsView.getWhisperInstalledModels()
|
||||
window.api.settingsView.getCurrentShortcuts()
|
||||
]);
|
||||
|
||||
if (userState && userState.isLoggedIn) this.firebaseUser = userState;
|
||||
|
||||
if (modelSettings.success) {
|
||||
const { config, storedKeys, availableLlm, availableStt, selectedModels } = modelSettings.data;
|
||||
this.providerConfig = config;
|
||||
this.apiKeys = storedKeys;
|
||||
this.availableLlmModels = availableLlm;
|
||||
this.availableSttModels = availableStt;
|
||||
this.selectedLlm = selectedModels.llm;
|
||||
this.selectedStt = selectedModels.stt;
|
||||
this.providerConfig = config;
|
||||
this.apiKeys = storedKeys;
|
||||
this.availableLlmModels = availableLlm;
|
||||
this.availableSttModels = availableStt;
|
||||
this.selectedLlm = selectedModels.llm;
|
||||
this.selectedStt = selectedModels.stt;
|
||||
}
|
||||
|
||||
this.presets = presets || [];
|
||||
@ -609,23 +640,9 @@ export class SettingsView extends LitElement {
|
||||
const firstUserPreset = this.presets.find(p => p.is_default === 0);
|
||||
if (firstUserPreset) this.selectedPreset = firstUserPreset;
|
||||
}
|
||||
// Ollama status
|
||||
if (ollamaStatus?.success) {
|
||||
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
|
||||
this.ollamaModels = ollamaStatus.models || [];
|
||||
}
|
||||
// Whisper status
|
||||
if (whisperModelsResult?.success) {
|
||||
const installedWhisperModels = whisperModelsResult.models;
|
||||
if (this.providerConfig.whisper) {
|
||||
this.providerConfig.whisper.sttModels.forEach(m => {
|
||||
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
|
||||
if (installedInfo) {
|
||||
m.installed = installedInfo.installed;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load LocalAI status asynchronously to improve initial load time
|
||||
this.loadLocalAIStatus();
|
||||
} catch (error) {
|
||||
console.error('Error loading initial settings data:', error);
|
||||
} finally {
|
||||
@ -779,16 +796,16 @@ export class SettingsView extends LitElement {
|
||||
this.installingModels = { ...this.installingModels, [modelName]: 0 };
|
||||
this.requestUpdate();
|
||||
|
||||
// 진행률 이벤트 리스너 설정
|
||||
// 진행률 이벤트 리스너 설정 - 통합 LocalAI 이벤트 사용
|
||||
const progressHandler = (event, data) => {
|
||||
if (data.modelId === modelName) {
|
||||
this.installingModels = { ...this.installingModels, [modelName]: data.progress };
|
||||
if (data.service === 'ollama' && data.model === modelName) {
|
||||
this.installingModels = { ...this.installingModels, [modelName]: data.progress || 0 };
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
// 진행률 이벤트 리스너 등록
|
||||
window.api.settingsView.onOllamaPullProgress(progressHandler);
|
||||
// 통합 LocalAI 이벤트 리스너 등록
|
||||
window.api.settingsView.onLocalAIInstallProgress(progressHandler);
|
||||
|
||||
try {
|
||||
const result = await window.api.settingsView.pullOllamaModel(modelName);
|
||||
@ -805,8 +822,8 @@ export class SettingsView extends LitElement {
|
||||
throw new Error(result.error || 'Installation failed');
|
||||
}
|
||||
} finally {
|
||||
// 진행률 이벤트 리스너 제거
|
||||
window.api.settingsView.removeOnOllamaPullProgress(progressHandler);
|
||||
// 통합 LocalAI 이벤트 리스너 제거
|
||||
window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SettingsView] Error installing model ${modelName}:`, error);
|
||||
@ -821,34 +838,52 @@ export class SettingsView extends LitElement {
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
// Set up progress listener
|
||||
const progressHandler = (event, { modelId: id, progress }) => {
|
||||
if (id === modelId) {
|
||||
this.installingModels = { ...this.installingModels, [modelId]: progress };
|
||||
// Set up progress listener - 통합 LocalAI 이벤트 사용
|
||||
const progressHandler = (event, data) => {
|
||||
if (data.service === 'whisper' && data.model === modelId) {
|
||||
this.installingModels = { ...this.installingModels, [modelId]: data.progress || 0 };
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
window.api.settingsView.onWhisperDownloadProgress(progressHandler);
|
||||
window.api.settingsView.onLocalAIInstallProgress(progressHandler);
|
||||
|
||||
// Start download
|
||||
const result = await window.api.settingsView.downloadWhisperModel(modelId);
|
||||
|
||||
if (result.success) {
|
||||
// Update the model's installed status
|
||||
if (this.providerConfig?.whisper?.sttModels) {
|
||||
const modelInfo = this.providerConfig.whisper.sttModels.find(m => m.id === modelId);
|
||||
if (modelInfo) {
|
||||
modelInfo.installed = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove from installing models
|
||||
delete this.installingModels[modelId];
|
||||
this.requestUpdate();
|
||||
|
||||
// Reload LocalAI status to get fresh data
|
||||
await this.loadLocalAIStatus();
|
||||
|
||||
// Auto-select the model after download
|
||||
await this.selectModel('stt', modelId);
|
||||
} else {
|
||||
// Remove from installing models on failure too
|
||||
delete this.installingModels[modelId];
|
||||
this.requestUpdate();
|
||||
alert(`Failed to download Whisper model: ${result.error}`);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
window.api.settingsView.removeOnWhisperDownloadProgress(progressHandler);
|
||||
window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
|
||||
} catch (error) {
|
||||
console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error);
|
||||
alert(`Error downloading ${modelId}: ${error.message}`);
|
||||
} finally {
|
||||
// Remove from installing models on error
|
||||
delete this.installingModels[modelId];
|
||||
this.requestUpdate();
|
||||
alert(`Error downloading ${modelId}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
@ -862,12 +897,6 @@ export class SettingsView extends LitElement {
|
||||
return null;
|
||||
}
|
||||
|
||||
async handleWhisperModelSelect(modelId) {
|
||||
if (!modelId) return;
|
||||
|
||||
// Select the model (will trigger download if needed)
|
||||
await this.selectModel('stt', modelId);
|
||||
}
|
||||
|
||||
handleUsePicklesKey(e) {
|
||||
e.preventDefault()
|
||||
@ -879,7 +908,7 @@ export class SettingsView extends LitElement {
|
||||
//////// after_modelStateService ////////
|
||||
|
||||
openShortcutEditor() {
|
||||
window.api.settingsView.openShortcutEditor();
|
||||
window.api.settingsView.openShortcutSettingsWindow();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@ -889,6 +918,8 @@ export class SettingsView extends LitElement {
|
||||
this.setupIpcListeners();
|
||||
this.setupWindowResize();
|
||||
this.loadAutoUpdateSetting();
|
||||
// Force one height calculation immediately (innerHeight may be 0 at first)
|
||||
setTimeout(() => this.updateScrollHeight(), 0);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
@ -927,7 +958,8 @@ export class SettingsView extends LitElement {
|
||||
this.firebaseUser = null;
|
||||
}
|
||||
this.loadAutoUpdateSetting();
|
||||
this.requestUpdate();
|
||||
// Reload model settings when user state changes (Firebase login/logout)
|
||||
this.loadInitialData();
|
||||
};
|
||||
|
||||
this._settingsUpdatedListener = (event, settings) => {
|
||||
@ -1000,8 +1032,10 @@ export class SettingsView extends LitElement {
|
||||
}
|
||||
|
||||
updateScrollHeight() {
|
||||
const windowHeight = window.innerHeight;
|
||||
const maxHeight = windowHeight;
|
||||
// Electron 일부 시점에서 window.innerHeight 가 0 으로 보고되는 버그 보호
|
||||
const rawHeight = window.innerHeight || (window.screen ? window.screen.height : 0);
|
||||
const MIN_HEIGHT = 300; // 최소 보장 높이
|
||||
const maxHeight = Math.max(MIN_HEIGHT, rawHeight);
|
||||
|
||||
this.style.maxHeight = `${maxHeight}px`;
|
||||
|
||||
@ -1013,19 +1047,15 @@ export class SettingsView extends LitElement {
|
||||
|
||||
handleMouseEnter = () => {
|
||||
window.api.settingsView.cancelHideSettingsWindow();
|
||||
// Recalculate height in case it was set to 0 before
|
||||
this.updateScrollHeight();
|
||||
}
|
||||
|
||||
handleMouseLeave = () => {
|
||||
window.api.settingsView.hideSettingsWindow();
|
||||
}
|
||||
|
||||
// getMainShortcuts() {
|
||||
// return [
|
||||
// { name: 'Show / Hide', key: '\\' },
|
||||
// { name: 'Ask Anything', key: '↵' },
|
||||
// { name: 'Scroll AI Response', key: '↕' }
|
||||
// ];
|
||||
// }
|
||||
|
||||
getMainShortcuts() {
|
||||
return [
|
||||
{ name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility },
|
||||
@ -1198,12 +1228,7 @@ export class SettingsView extends LitElement {
|
||||
}
|
||||
|
||||
if (id === 'whisper') {
|
||||
// Special UI for Whisper with model selection
|
||||
const whisperModels = config.sttModels || [];
|
||||
const selectedWhisperModel = this.selectedStt && this.getProviderForModel('stt', this.selectedStt) === 'whisper'
|
||||
? this.selectedStt
|
||||
: null;
|
||||
|
||||
// Simplified UI for Whisper without model selection
|
||||
return html`
|
||||
<div class="provider-key-group">
|
||||
<label>${config.name} (Local STT)</label>
|
||||
@ -1211,51 +1236,6 @@ export class SettingsView extends LitElement {
|
||||
<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>
|
||||
|
||||
<!-- Whisper Model Selection Dropdown -->
|
||||
<label style="font-size: 10px; margin-top: 8px;">Select Model:</label>
|
||||
<select
|
||||
class="model-dropdown"
|
||||
style="width: 100%; padding: 6px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; font-size: 11px; margin-bottom: 8px;"
|
||||
@change=${(e) => this.handleWhisperModelSelect(e.target.value)}
|
||||
.value=${selectedWhisperModel || ''}
|
||||
>
|
||||
<option value="">Choose a model...</option>
|
||||
${whisperModels.map(model => {
|
||||
const isInstalling = this.installingModels[model.id] !== undefined;
|
||||
const progress = this.installingModels[model.id] || 0;
|
||||
|
||||
let statusText = '';
|
||||
if (isInstalling) {
|
||||
statusText = ` (Downloading ${progress}%)`;
|
||||
} else if (model.installed) {
|
||||
statusText = ' (Installed)';
|
||||
}
|
||||
|
||||
return html`
|
||||
<option value="${model.id}" ?disabled=${isInstalling}>
|
||||
${model.name}${statusText}
|
||||
</option>
|
||||
`;
|
||||
})}
|
||||
</select>
|
||||
|
||||
${Object.entries(this.installingModels).map(([modelId, progress]) => {
|
||||
if (modelId.startsWith('whisper-') && progress !== undefined) {
|
||||
return html`
|
||||
<div style="margin: 8px 0;">
|
||||
<div style="font-size: 10px; color: rgba(255,255,255,0.7); margin-bottom: 4px;">
|
||||
Downloading ${modelId}...
|
||||
</div>
|
||||
<div class="install-progress" style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden;">
|
||||
<div class="install-progress-bar" style="height: 100%; background: rgba(0, 122, 255, 0.8); width: ${progress}%; transition: width 0.3s ease;"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
<button class="settings-button full-width danger" @click=${() => this.handleClearKey(id)}>
|
||||
Disable Whisper
|
||||
</button>
|
||||
@ -1337,6 +1317,9 @@ export class SettingsView extends LitElement {
|
||||
<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;
|
||||
|
||||
@ -1344,10 +1327,16 @@ export class SettingsView extends LitElement {
|
||||
<div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}"
|
||||
@click=${() => this.selectModel('stt', model.id)}>
|
||||
<span>${model.name}</span>
|
||||
${isWhisper && isInstalling ? html`
|
||||
<div class="install-progress">
|
||||
<div class="install-progress-bar" style="width: ${installProgress}%"></div>
|
||||
</div>
|
||||
${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>
|
||||
`;
|
||||
|
@ -171,6 +171,7 @@ export class ShortcutSettingsView extends LitElement {
|
||||
|
||||
async handleSave() {
|
||||
if (!window.api) return;
|
||||
this.feedback = {};
|
||||
const result = await window.api.shortcutSettingsView.saveShortcuts(this.shortcuts);
|
||||
if (!result.success) {
|
||||
alert('Failed to save shortcuts: ' + result.error);
|
||||
@ -179,7 +180,8 @@ export class ShortcutSettingsView extends LitElement {
|
||||
|
||||
handleClose() {
|
||||
if (!window.api) return;
|
||||
window.api.shortcutSettingsView.closeShortcutEditor();
|
||||
this.feedback = {};
|
||||
window.api.shortcutSettingsView.closeShortcutSettingsWindow();
|
||||
}
|
||||
|
||||
async handleResetToDefault() {
|
||||
|
@ -1,11 +1,8 @@
|
||||
const { screen } = require('electron');
|
||||
|
||||
class SmoothMovementManager {
|
||||
constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
|
||||
constructor(windowPool) {
|
||||
this.windowPool = windowPool;
|
||||
this.getDisplayById = getDisplayById;
|
||||
this.getCurrentDisplay = getCurrentDisplay;
|
||||
this.updateLayout = updateLayout;
|
||||
this.stepSize = 80;
|
||||
this.animationDuration = 300;
|
||||
this.headerPosition = { x: 0, y: 0 };
|
||||
@ -14,6 +11,8 @@ class SmoothMovementManager {
|
||||
this.lastVisiblePosition = null;
|
||||
this.currentDisplayId = null;
|
||||
this.animationFrameId = null;
|
||||
|
||||
this.animationTimers = new Map();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -22,248 +21,162 @@ class SmoothMovementManager {
|
||||
*/
|
||||
_isWindowValid(win) {
|
||||
if (!win || win.isDestroyed()) {
|
||||
if (this.isAnimating) {
|
||||
console.warn('[MovementManager] Window destroyed mid-animation. Halting.');
|
||||
this.isAnimating = false;
|
||||
if (this.animationFrameId) {
|
||||
clearTimeout(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
// 해당 창의 타이머가 있으면 정리
|
||||
if (this.animationTimers.has(win)) {
|
||||
clearTimeout(this.animationTimers.get(win));
|
||||
this.animationTimers.delete(win);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
moveToDisplay(displayId) {
|
||||
const header = this.windowPool.get('header');
|
||||
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
|
||||
|
||||
const targetDisplay = this.getDisplayById(displayId);
|
||||
if (!targetDisplay) return;
|
||||
|
||||
const currentBounds = header.getBounds();
|
||||
const currentDisplay = this.getCurrentDisplay(header);
|
||||
|
||||
if (currentDisplay.id === targetDisplay.id) return;
|
||||
|
||||
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
|
||||
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
|
||||
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
|
||||
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
|
||||
|
||||
const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
|
||||
const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
|
||||
|
||||
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
|
||||
this.animateToPosition(header, finalX, finalY);
|
||||
this.currentDisplayId = targetDisplay.id;
|
||||
}
|
||||
|
||||
hideToEdge(edge, callback, { instant = false } = {}) {
|
||||
const header = this.windowPool.get('header');
|
||||
if (!header || header.isDestroyed()) {
|
||||
if (typeof callback === 'function') callback();
|
||||
/**
|
||||
*
|
||||
* @param {BrowserWindow} win
|
||||
* @param {number} targetX
|
||||
* @param {number} targetY
|
||||
* @param {object} [options]
|
||||
* @param {object} [options.sizeOverride]
|
||||
* @param {function} [options.onComplete]
|
||||
* @param {number} [options.duration]
|
||||
*/
|
||||
animateWindow(win, targetX, targetY, options = {}) {
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (options.onComplete) options.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const { x, y } = header.getBounds();
|
||||
this.lastVisiblePosition = { x, y };
|
||||
this.hiddenPosition = { edge };
|
||||
|
||||
if (instant) {
|
||||
header.hide();
|
||||
if (typeof callback === 'function') callback();
|
||||
return;
|
||||
}
|
||||
|
||||
header.webContents.send('window-hide-animation');
|
||||
|
||||
setTimeout(() => {
|
||||
if (!header.isDestroyed()) header.hide();
|
||||
if (typeof callback === 'function') callback();
|
||||
}, 5);
|
||||
}
|
||||
|
||||
showFromEdge(callback) {
|
||||
const header = this.windowPool.get('header');
|
||||
if (!header || header.isDestroyed()) {
|
||||
if (typeof callback === 'function') callback();
|
||||
return;
|
||||
}
|
||||
|
||||
// 숨기기 전에 기억해둔 위치 복구
|
||||
if (this.lastVisiblePosition) {
|
||||
header.setPosition(
|
||||
this.lastVisiblePosition.x,
|
||||
this.lastVisiblePosition.y,
|
||||
false // animate: false
|
||||
);
|
||||
}
|
||||
|
||||
header.show();
|
||||
header.webContents.send('window-show-animation');
|
||||
|
||||
// 내부 상태 초기화
|
||||
this.hiddenPosition = null;
|
||||
this.lastVisiblePosition = null;
|
||||
|
||||
if (typeof callback === 'function') callback();
|
||||
}
|
||||
|
||||
moveStep(direction) {
|
||||
const header = this.windowPool.get('header');
|
||||
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
|
||||
|
||||
const currentBounds = header.getBounds();
|
||||
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
|
||||
let targetX = this.headerPosition.x;
|
||||
let targetY = this.headerPosition.y;
|
||||
|
||||
console.log(`[MovementManager] Moving ${direction} from (${targetX}, ${targetY})`);
|
||||
|
||||
const windowSize = {
|
||||
width: currentBounds.width,
|
||||
height: currentBounds.height
|
||||
};
|
||||
|
||||
switch (direction) {
|
||||
case 'left': targetX -= this.stepSize; break;
|
||||
case 'right': targetX += this.stepSize; break;
|
||||
case 'up': targetY -= this.stepSize; break;
|
||||
case 'down': targetY += this.stepSize; break;
|
||||
default: return;
|
||||
}
|
||||
|
||||
// Find the display that contains or is nearest to the target position
|
||||
const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
|
||||
const { x: workAreaX, y: workAreaY, width: workAreaWidth, height: workAreaHeight } = nearestDisplay.workArea;
|
||||
|
||||
// Only clamp if the target position would actually go out of bounds
|
||||
let clampedX = targetX;
|
||||
let clampedY = targetY;
|
||||
|
||||
// Check horizontal bounds
|
||||
if (targetX < workAreaX) {
|
||||
clampedX = workAreaX;
|
||||
} else if (targetX + currentBounds.width > workAreaX + workAreaWidth) {
|
||||
clampedX = workAreaX + workAreaWidth - currentBounds.width;
|
||||
}
|
||||
|
||||
// Check vertical bounds
|
||||
if (targetY < workAreaY) {
|
||||
clampedY = workAreaY;
|
||||
console.log(`[MovementManager] Clamped Y to top edge: ${clampedY}`);
|
||||
} else if (targetY + currentBounds.height > workAreaY + workAreaHeight) {
|
||||
clampedY = workAreaY + workAreaHeight - currentBounds.height;
|
||||
console.log(`[MovementManager] Clamped Y to bottom edge: ${clampedY}`);
|
||||
}
|
||||
|
||||
console.log(`[MovementManager] Final position: (${clampedX}, ${clampedY}), Work area: ${workAreaX},${workAreaY} ${workAreaWidth}x${workAreaHeight}`);
|
||||
|
||||
// Only move if there's an actual change in position
|
||||
if (clampedX === this.headerPosition.x && clampedY === this.headerPosition.y) {
|
||||
console.log(`[MovementManager] No position change, skipping animation`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.animateToPosition(header, clampedX, clampedY, windowSize);
|
||||
}
|
||||
|
||||
animateToPosition(header, targetX, targetY, windowSize) {
|
||||
if (!this._isWindowValid(header)) return;
|
||||
|
||||
this.isAnimating = true;
|
||||
const startX = this.headerPosition.x;
|
||||
const startY = this.headerPosition.y;
|
||||
const { sizeOverride, onComplete, duration: animDuration } = options;
|
||||
const start = win.getBounds();
|
||||
const startTime = Date.now();
|
||||
const duration = animDuration || this.animationDuration;
|
||||
const { width, height } = sizeOverride || start;
|
||||
|
||||
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
|
||||
this.isAnimating = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const animate = () => {
|
||||
if (!this._isWindowValid(header)) return;
|
||||
|
||||
const elapsed = Date.now() - startTime;
|
||||
const progress = Math.min(elapsed / this.animationDuration, 1);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
const currentX = startX + (targetX - startX) * eased;
|
||||
const currentY = startY + (targetY - startY) * eased;
|
||||
|
||||
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
|
||||
this.isAnimating = false;
|
||||
const step = () => {
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this._isWindowValid(header)) return;
|
||||
const { width, height } = windowSize || header.getBounds();
|
||||
header.setBounds({
|
||||
x: Math.round(currentX),
|
||||
y: Math.round(currentY),
|
||||
width,
|
||||
height
|
||||
});
|
||||
const p = Math.min((Date.now() - startTime) / duration, 1);
|
||||
const eased = 1 - Math.pow(1 - p, 3); // ease-out-cubic
|
||||
const x = start.x + (targetX - start.x) * eased;
|
||||
const y = start.y + (targetY - start.y) * eased;
|
||||
|
||||
if (progress < 1) {
|
||||
this.animationFrameId = setTimeout(animate, 8);
|
||||
win.setBounds({ x: Math.round(x), y: Math.round(y), width, height });
|
||||
|
||||
if (p < 1) {
|
||||
setTimeout(step, 8);
|
||||
} else {
|
||||
this.animationFrameId = null;
|
||||
this.isAnimating = false;
|
||||
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
|
||||
if (!this._isWindowValid(header)) return;
|
||||
header.setPosition(Math.round(targetX), Math.round(targetY));
|
||||
// Update header position to the actual final position
|
||||
this.headerPosition = { x: Math.round(targetX), y: Math.round(targetY) };
|
||||
this.layoutManager.updateLayout();
|
||||
if (onComplete) {
|
||||
onComplete();
|
||||
}
|
||||
this.updateLayout();
|
||||
}
|
||||
};
|
||||
animate();
|
||||
step();
|
||||
}
|
||||
|
||||
moveToEdge(direction) {
|
||||
const header = this.windowPool.get('header');
|
||||
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
|
||||
fade(win, { from, to, duration = 250, onComplete }) {
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (onComplete) onComplete();
|
||||
return;
|
||||
}
|
||||
const startOpacity = from ?? win.getOpacity();
|
||||
const startTime = Date.now();
|
||||
|
||||
const display = this.getCurrentDisplay(header);
|
||||
const { width, height } = display.workAreaSize;
|
||||
const { x: workAreaX, y: workAreaY } = display.workArea;
|
||||
const currentBounds = header.getBounds();
|
||||
const step = () => {
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (onComplete) onComplete(); return;
|
||||
}
|
||||
const progress = Math.min(1, (Date.now() - startTime) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
win.setOpacity(startOpacity + (to - startOpacity) * eased);
|
||||
|
||||
const windowSize = {
|
||||
width: currentBounds.width,
|
||||
height: currentBounds.height
|
||||
if (progress < 1) {
|
||||
setTimeout(step, 8);
|
||||
} else {
|
||||
win.setOpacity(to);
|
||||
if (onComplete) onComplete();
|
||||
}
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
||||
let targetX = currentBounds.x;
|
||||
let targetY = currentBounds.y;
|
||||
|
||||
switch (direction) {
|
||||
case 'left':
|
||||
targetX = workAreaX;
|
||||
break;
|
||||
case 'right':
|
||||
targetX = workAreaX + width - windowSize.width;
|
||||
break;
|
||||
case 'up':
|
||||
targetY = workAreaY;
|
||||
break;
|
||||
case 'down':
|
||||
targetY = workAreaY + height - windowSize.height;
|
||||
break;
|
||||
animateWindowBounds(win, targetBounds, options = {}) {
|
||||
if (this.animationTimers.has(win)) {
|
||||
clearTimeout(this.animationTimers.get(win));
|
||||
}
|
||||
|
||||
header.setBounds({
|
||||
x: Math.round(targetX),
|
||||
y: Math.round(targetY),
|
||||
width: windowSize.width,
|
||||
height: windowSize.height
|
||||
});
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (options.onComplete) options.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
this.headerPosition = { x: targetX, y: targetY };
|
||||
this.updateLayout();
|
||||
this.isAnimating = true;
|
||||
|
||||
const startBounds = win.getBounds();
|
||||
const startTime = Date.now();
|
||||
const duration = options.duration || this.animationDuration;
|
||||
|
||||
const step = () => {
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (options.onComplete) options.onComplete();
|
||||
return;
|
||||
}
|
||||
|
||||
const progress = Math.min(1, (Date.now() - startTime) / duration);
|
||||
const eased = 1 - Math.pow(1 - progress, 3);
|
||||
|
||||
const newBounds = {
|
||||
x: Math.round(startBounds.x + (targetBounds.x - startBounds.x) * eased),
|
||||
y: Math.round(startBounds.y + (targetBounds.y - startBounds.y) * eased),
|
||||
width: Math.round(startBounds.width + ((targetBounds.width ?? startBounds.width) - startBounds.width) * eased),
|
||||
height: Math.round(startBounds.height + ((targetBounds.height ?? startBounds.height) - startBounds.height) * eased),
|
||||
};
|
||||
win.setBounds(newBounds);
|
||||
|
||||
if (progress < 1) {
|
||||
const timerId = setTimeout(step, 8);
|
||||
this.animationTimers.set(win, timerId);
|
||||
} else {
|
||||
win.setBounds(targetBounds);
|
||||
this.animationTimers.delete(win);
|
||||
|
||||
if (this.animationTimers.size === 0) {
|
||||
this.isAnimating = false;
|
||||
}
|
||||
|
||||
if (options.onComplete) options.onComplete();
|
||||
}
|
||||
};
|
||||
step();
|
||||
}
|
||||
|
||||
animateWindowPosition(win, targetPosition, options = {}) {
|
||||
if (!this._isWindowValid(win)) {
|
||||
if (options.onComplete) options.onComplete();
|
||||
return;
|
||||
}
|
||||
const currentBounds = win.getBounds();
|
||||
const targetBounds = { ...currentBounds, ...targetPosition };
|
||||
this.animateWindowBounds(win, targetBounds, options);
|
||||
}
|
||||
|
||||
animateLayout(layout, animated = true) {
|
||||
if (!layout) return;
|
||||
for (const winName in layout) {
|
||||
const win = this.windowPool.get(winName);
|
||||
const targetBounds = layout[winName];
|
||||
if (win && !win.isDestroyed() && targetBounds) {
|
||||
if (animated) {
|
||||
this.animateWindowBounds(win, targetBounds);
|
||||
} else {
|
||||
win.setBounds(targetBounds);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
|
@ -1,9 +1,9 @@
|
||||
const { screen } = require('electron');
|
||||
|
||||
/**
|
||||
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다.
|
||||
* @param {BrowserWindow} window - 확인할 창 객체
|
||||
* @returns {Display} Electron의 Display 객체
|
||||
*
|
||||
* @param {BrowserWindow} window
|
||||
* @returns {Display}
|
||||
*/
|
||||
function getCurrentDisplay(window) {
|
||||
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
|
||||
@ -27,53 +27,28 @@ class WindowLayoutManager {
|
||||
this.PADDING = 80;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 창의 레이아웃 업데이트를 요청합니다.
|
||||
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
|
||||
*/
|
||||
updateLayout() {
|
||||
if (this.isUpdating) return;
|
||||
this.isUpdating = true;
|
||||
|
||||
setImmediate(() => {
|
||||
this.positionWindows();
|
||||
this.isUpdating = false;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
|
||||
*/
|
||||
positionWindows() {
|
||||
getHeaderPosition = () => {
|
||||
const header = this.windowPool.get('header');
|
||||
if (!header?.getBounds) return;
|
||||
if (header) {
|
||||
const [x, y] = header.getPosition();
|
||||
return { x, y };
|
||||
}
|
||||
return { x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const headerBounds = header.getBounds();
|
||||
const display = getCurrentDisplay(header);
|
||||
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
|
||||
const { x: workAreaX, y: workAreaY } = display.workArea;
|
||||
|
||||
const headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
|
||||
const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
|
||||
|
||||
const relativeX = headerCenterX / screenWidth;
|
||||
const relativeY = headerCenterY / screenHeight;
|
||||
|
||||
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY);
|
||||
|
||||
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
|
||||
this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 창의 위치에 따라 기능 창들을 배치할 최적의 전략을 결정합니다.
|
||||
* @returns {{name: string, primary: string, secondary: string}} 레이아웃 전략
|
||||
*
|
||||
* @returns {{name: string, primary: string, secondary: string}}
|
||||
*/
|
||||
determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY) {
|
||||
const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height);
|
||||
const spaceAbove = headerBounds.y;
|
||||
const spaceLeft = headerBounds.x;
|
||||
const spaceRight = screenWidth - (headerBounds.x + headerBounds.width);
|
||||
determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY) {
|
||||
const headerRelX = headerBounds.x - workAreaX;
|
||||
const headerRelY = headerBounds.y - workAreaY;
|
||||
|
||||
const spaceBelow = screenHeight - (headerRelY + headerBounds.height);
|
||||
const spaceAbove = headerRelY;
|
||||
const spaceLeft = headerRelX;
|
||||
const spaceRight = screenWidth - (headerRelX + headerBounds.width);
|
||||
|
||||
if (spaceBelow >= 400) {
|
||||
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' };
|
||||
@ -88,120 +63,242 @@ class WindowLayoutManager {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 'ask'와 'listen' 창의 위치를 조정합니다.
|
||||
* @returns {{x: number, y: number} | null}
|
||||
*/
|
||||
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
|
||||
calculateSettingsWindowPosition() {
|
||||
const header = this.windowPool.get('header');
|
||||
const settings = this.windowPool.get('settings');
|
||||
|
||||
if (!header || header.isDestroyed() || !settings || settings.isDestroyed()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerBounds = header.getBounds();
|
||||
const settingsBounds = settings.getBounds();
|
||||
const display = getCurrentDisplay(header);
|
||||
const { x: workAreaX, y: workAreaY, width: screenWidth, height: screenHeight } = display.workArea;
|
||||
|
||||
const PAD = 5;
|
||||
const buttonPadding = 170;
|
||||
|
||||
const x = headerBounds.x + headerBounds.width - settingsBounds.width + buttonPadding;
|
||||
const y = headerBounds.y + headerBounds.height + PAD;
|
||||
|
||||
const clampedX = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
|
||||
const clampedY = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
|
||||
|
||||
return { x: Math.round(clampedX), y: Math.round(clampedY) };
|
||||
}
|
||||
|
||||
|
||||
calculateHeaderResize(header, { width, height }) {
|
||||
if (!header) return null;
|
||||
const currentBounds = header.getBounds();
|
||||
const centerX = currentBounds.x + currentBounds.width / 2;
|
||||
const newX = Math.round(centerX - width / 2);
|
||||
const display = getCurrentDisplay(header);
|
||||
const { x: workAreaX, width: workAreaWidth } = display.workArea;
|
||||
const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX));
|
||||
return { x: clampedX, y: currentBounds.y, width, height };
|
||||
}
|
||||
|
||||
calculateClampedPosition(header, { x: newX, y: newY }) {
|
||||
if (!header) return null;
|
||||
const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
|
||||
const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
|
||||
const headerBounds = header.getBounds();
|
||||
const clampedX = Math.max(workAreaX, Math.min(newX, workAreaX + width - headerBounds.width));
|
||||
const clampedY = Math.max(workAreaY, Math.min(newY, workAreaY + height - headerBounds.height));
|
||||
return { x: clampedX, y: clampedY };
|
||||
}
|
||||
|
||||
calculateWindowHeightAdjustment(senderWindow, targetHeight) {
|
||||
if (!senderWindow) return null;
|
||||
const currentBounds = senderWindow.getBounds();
|
||||
const minHeight = senderWindow.getMinimumSize()[1];
|
||||
const maxHeight = senderWindow.getMaximumSize()[1];
|
||||
let adjustedHeight = Math.max(minHeight, targetHeight);
|
||||
if (maxHeight > 0) {
|
||||
adjustedHeight = Math.min(maxHeight, adjustedHeight);
|
||||
}
|
||||
console.log(`[Layout Debug] calculateWindowHeightAdjustment: targetHeight=${targetHeight}`);
|
||||
return { ...currentBounds, height: adjustedHeight };
|
||||
}
|
||||
|
||||
// 기존 getTargetBoundsForFeatureWindows를 이 함수로 대체합니다.
|
||||
calculateFeatureWindowLayout(visibility, headerBoundsOverride = null) {
|
||||
const header = this.windowPool.get('header');
|
||||
const headerBounds = headerBoundsOverride || (header ? header.getBounds() : null);
|
||||
|
||||
if (!headerBounds) return {};
|
||||
|
||||
let display;
|
||||
if (headerBoundsOverride) {
|
||||
const boundsCenter = {
|
||||
x: headerBounds.x + headerBounds.width / 2,
|
||||
y: headerBounds.y + headerBounds.height / 2,
|
||||
};
|
||||
display = screen.getDisplayNearestPoint(boundsCenter);
|
||||
} else {
|
||||
display = getCurrentDisplay(header);
|
||||
}
|
||||
|
||||
const { width: screenWidth, height: screenHeight, x: workAreaX, y: workAreaY } = display.workArea;
|
||||
|
||||
const ask = this.windowPool.get('ask');
|
||||
const listen = this.windowPool.get('listen');
|
||||
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
|
||||
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
|
||||
|
||||
if (!askVisible && !listenVisible) return;
|
||||
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;
|
||||
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;
|
||||
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;
|
||||
|
||||
if (askVis) {
|
||||
console.log(`[Layout Debug] Ask Window Bounds: height=${askB.height}, width=${askB.width}`);
|
||||
}
|
||||
if (listenVis) {
|
||||
console.log(`[Layout Debug] Listen Window Bounds: height=${listenB.height}, width=${listenB.width}`);
|
||||
}
|
||||
|
||||
const layout = {};
|
||||
|
||||
if (askVis && listenVis) {
|
||||
let askXRel = headerCenterXRel - (askB.width / 2);
|
||||
let listenXRel = askXRel - listenB.width - PAD;
|
||||
|
||||
if (listenXRel < PAD) {
|
||||
listenXRel = PAD;
|
||||
askXRel = listenXRel + listenBounds.width + PAD;
|
||||
askXRel = listenXRel + listenB.width + PAD;
|
||||
}
|
||||
if (askXRel + askBounds.width > screenWidth - PAD) {
|
||||
askXRel = screenWidth - PAD - askBounds.width;
|
||||
listenXRel = askXRel - listenBounds.width - PAD;
|
||||
if (askXRel + askB.width > screenWidth - PAD) {
|
||||
askXRel = screenWidth - PAD - askB.width;
|
||||
listenXRel = askXRel - listenB.width - PAD;
|
||||
}
|
||||
|
||||
let yRel = (strategy.primary === 'above')
|
||||
? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD
|
||||
: headerBounds.y - workAreaY + headerBounds.height + 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 {};
|
||||
|
||||
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;
|
||||
let xRel = headerCenterXRel - winB.width / 2;
|
||||
xRel = Math.max(PAD, Math.min(screenWidth - winB.width - PAD, xRel));
|
||||
|
||||
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
|
||||
yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
|
||||
let yPos;
|
||||
if (strategy.primary === 'above') {
|
||||
yPos = (headerBounds.y - workAreaY) - PAD - winB.height;
|
||||
} else { // 'below'
|
||||
yPos = (headerBounds.y - workAreaY) + headerBounds.height + PAD;
|
||||
}
|
||||
|
||||
win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), width: winBounds.width, height: winBounds.height });
|
||||
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 };
|
||||
}
|
||||
|
||||
calculateStepMovePosition(header, direction) {
|
||||
if (!header) return null;
|
||||
const currentBounds = header.getBounds();
|
||||
const stepSize = 80; // 이동 간격
|
||||
let targetX = currentBounds.x;
|
||||
let targetY = currentBounds.y;
|
||||
|
||||
switch (direction) {
|
||||
case 'left': targetX -= stepSize; break;
|
||||
case 'right': targetX += stepSize; break;
|
||||
case 'up': targetY -= stepSize; break;
|
||||
case 'down': targetY += stepSize; break;
|
||||
}
|
||||
|
||||
return this.calculateClampedPosition(header, { x: targetX, y: targetY });
|
||||
}
|
||||
|
||||
calculateEdgePosition(header, direction) {
|
||||
if (!header) return null;
|
||||
const display = getCurrentDisplay(header);
|
||||
const { workArea } = display;
|
||||
const currentBounds = header.getBounds();
|
||||
|
||||
let targetX = currentBounds.x;
|
||||
let targetY = currentBounds.y;
|
||||
|
||||
switch (direction) {
|
||||
case 'left': targetX = workArea.x; break;
|
||||
case 'right': targetX = workArea.x + workArea.width - currentBounds.width; break;
|
||||
case 'up': targetY = workArea.y; break;
|
||||
case 'down': targetY = workArea.y + workArea.height - currentBounds.height; break;
|
||||
}
|
||||
return { x: targetX, y: targetY };
|
||||
}
|
||||
|
||||
calculateNewPositionForDisplay(window, targetDisplayId) {
|
||||
if (!window) return null;
|
||||
|
||||
const targetDisplay = screen.getAllDisplays().find(d => d.id === targetDisplayId);
|
||||
if (!targetDisplay) return null;
|
||||
|
||||
const currentBounds = window.getBounds();
|
||||
const currentDisplay = getCurrentDisplay(window);
|
||||
|
||||
if (currentDisplay.id === targetDisplay.id) return { x: currentBounds.x, y: currentBounds.y };
|
||||
|
||||
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workArea.width;
|
||||
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workArea.height;
|
||||
|
||||
const targetX = targetDisplay.workArea.x + targetDisplay.workArea.width * relativeX;
|
||||
const targetY = targetDisplay.workArea.y + targetDisplay.workArea.height * relativeY;
|
||||
|
||||
const clampedX = Math.max(targetDisplay.workArea.x, Math.min(targetX, targetDisplay.workArea.x + targetDisplay.workArea.width - currentBounds.width));
|
||||
const clampedY = Math.max(targetDisplay.workArea.y, Math.min(targetY, targetDisplay.workArea.y + targetDisplay.workArea.height - currentBounds.height));
|
||||
|
||||
return { x: Math.round(clampedX), y: Math.round(clampedY) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 'settings' 창의 위치를 조정합니다.
|
||||
*/
|
||||
positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
|
||||
const settings = this.windowPool.get('settings');
|
||||
if (!settings?.getBounds || !settings.isVisible()) return;
|
||||
|
||||
if (settings.__lockedByButton) {
|
||||
const headerDisplay = getCurrentDisplay(this.windowPool.get('header'));
|
||||
const settingsDisplay = getCurrentDisplay(settings);
|
||||
if (headerDisplay.id !== settingsDisplay.id) {
|
||||
settings.__lockedByButton = false;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const settingsBounds = settings.getBounds();
|
||||
const PAD = 5;
|
||||
const buttonPadding = 17;
|
||||
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
|
||||
let y = headerBounds.y + headerBounds.height + PAD;
|
||||
|
||||
const otherVisibleWindows = [];
|
||||
['listen', 'ask'].forEach(name => {
|
||||
const win = this.windowPool.get(name);
|
||||
if (win && win.isVisible() && !win.isDestroyed()) {
|
||||
otherVisibleWindows.push({ name, bounds: win.getBounds() });
|
||||
}
|
||||
});
|
||||
|
||||
const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height };
|
||||
let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds));
|
||||
|
||||
if (hasOverlap) {
|
||||
x = headerBounds.x + headerBounds.width + PAD;
|
||||
y = headerBounds.y;
|
||||
if (x + settingsBounds.width > screenWidth - 10) {
|
||||
x = headerBounds.x - settingsBounds.width - PAD;
|
||||
}
|
||||
if (x < 10) {
|
||||
x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
|
||||
y = headerBounds.y - settingsBounds.height - PAD;
|
||||
if (y < 10) {
|
||||
x = headerBounds.x + headerBounds.width - settingsBounds.width;
|
||||
y = headerBounds.y + headerBounds.height + PAD;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
x = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
|
||||
y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
|
||||
|
||||
settings.setBounds({ x: Math.round(x), y: Math.round(y) });
|
||||
settings.moveTop();
|
||||
}
|
||||
|
||||
/**
|
||||
* 두 사각형 영역이 겹치는지 확인합니다.
|
||||
* @param {Rectangle} bounds1
|
||||
* @param {Rectangle} bounds2
|
||||
* @returns {boolean} 겹침 여부
|
||||
* @returns {boolean}
|
||||
*/
|
||||
boundsOverlap(bounds1, bounds2) {
|
||||
const margin = 10;
|
||||
|
@ -7,53 +7,6 @@ const shortcutsService = require('../features/shortcuts/shortcutsService');
|
||||
const internalBridge = require('../bridge/internalBridge');
|
||||
const permissionRepository = require('../features/common/repositories/permission');
|
||||
|
||||
// internalBridge 이벤트 리스너 설정
|
||||
function setupInternalBridgeListeners() {
|
||||
// 창 표시/숨기기 요청
|
||||
internalBridge.on('show-window', (windowName, options = {}) => {
|
||||
console.log(`[WindowManager] Received show-window request for: ${windowName}`);
|
||||
switch (windowName) {
|
||||
case 'settings':
|
||||
showSettingsWindow(options.bounds);
|
||||
break;
|
||||
case 'ask':
|
||||
ensureAskWindowVisible();
|
||||
break;
|
||||
default:
|
||||
console.warn(`[WindowManager] Unknown window name: ${windowName}`);
|
||||
}
|
||||
});
|
||||
|
||||
internalBridge.on('hide-window', (windowName) => {
|
||||
console.log(`[WindowManager] Received hide-window request for: ${windowName}`);
|
||||
switch (windowName) {
|
||||
case 'settings':
|
||||
hideSettingsWindow();
|
||||
break;
|
||||
case 'ask':
|
||||
closeAskWindow();
|
||||
break;
|
||||
default:
|
||||
console.warn(`[WindowManager] Unknown window name: ${windowName}`);
|
||||
}
|
||||
});
|
||||
|
||||
internalBridge.on('toggle-visibility', () => {
|
||||
console.log(`[WindowManager] Received toggle-visibility request`);
|
||||
toggleAllWindowsVisibility();
|
||||
});
|
||||
|
||||
internalBridge.on('set-content-protection', (enabled) => {
|
||||
console.log(`[WindowManager] Received set-content-protection request: ${enabled}`);
|
||||
setContentProtection(enabled);
|
||||
});
|
||||
|
||||
console.log('[WindowManager] Internal bridge listeners configured');
|
||||
}
|
||||
|
||||
// 초기화 시 내부 브릿지 리스너 설정
|
||||
setupInternalBridgeListeners();
|
||||
|
||||
/* ────────────────[ GLASS BYPASS ]─────────────── */
|
||||
let liquidGlass;
|
||||
const isLiquidGlassSupported = () => {
|
||||
@ -76,30 +29,381 @@ if (shouldUseLiquidGlass) {
|
||||
/* ────────────────[ GLASS BYPASS ]─────────────── */
|
||||
|
||||
let isContentProtectionOn = true;
|
||||
let currentDisplayId = null;
|
||||
|
||||
let mouseEventsIgnored = false;
|
||||
let lastVisibleWindows = new Set(['header']);
|
||||
const HEADER_HEIGHT = 47;
|
||||
const DEFAULT_WINDOW_WIDTH = 353;
|
||||
|
||||
let currentHeaderState = 'apikey';
|
||||
const windowPool = new Map();
|
||||
let fixedYPosition = 0;
|
||||
|
||||
let settingsHideTimer = null;
|
||||
|
||||
let selectedCaptureSourceId = null;
|
||||
|
||||
// let shortcutEditorWindow = null;
|
||||
let layoutManager = null;
|
||||
function updateLayout() {
|
||||
if (layoutManager) {
|
||||
layoutManager.updateLayout();
|
||||
let movementManager = null;
|
||||
|
||||
|
||||
function updateChildWindowLayouts(animated = true) {
|
||||
// if (movementManager.isAnimating) return;
|
||||
|
||||
const visibleWindows = {};
|
||||
const listenWin = windowPool.get('listen');
|
||||
const askWin = windowPool.get('ask');
|
||||
if (listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) {
|
||||
visibleWindows.listen = true;
|
||||
}
|
||||
if (askWin && !askWin.isDestroyed() && askWin.isVisible()) {
|
||||
visibleWindows.ask = true;
|
||||
}
|
||||
|
||||
if (Object.keys(visibleWindows).length === 0) return;
|
||||
|
||||
const newLayout = layoutManager.calculateFeatureWindowLayout(visibleWindows);
|
||||
movementManager.animateLayout(newLayout, animated);
|
||||
}
|
||||
|
||||
const showSettingsWindow = () => {
|
||||
internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
|
||||
};
|
||||
|
||||
const hideSettingsWindow = () => {
|
||||
internalBridge.emit('window:requestVisibility', { name: 'settings', visible: false });
|
||||
};
|
||||
|
||||
const cancelHideSettingsWindow = () => {
|
||||
internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
|
||||
};
|
||||
|
||||
const moveWindowStep = (direction) => {
|
||||
internalBridge.emit('window:moveStep', { direction });
|
||||
};
|
||||
|
||||
const resizeHeaderWindow = ({ width, height }) => {
|
||||
internalBridge.emit('window:resizeHeaderWindow', { width, height });
|
||||
};
|
||||
|
||||
const handleHeaderAnimationFinished = (state) => {
|
||||
internalBridge.emit('window:headerAnimationFinished', state);
|
||||
};
|
||||
|
||||
const getHeaderPosition = () => {
|
||||
return new Promise((resolve) => {
|
||||
internalBridge.emit('window:getHeaderPosition', (position) => {
|
||||
resolve(position);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const moveHeaderTo = (newX, newY) => {
|
||||
internalBridge.emit('window:moveHeaderTo', { newX, newY });
|
||||
};
|
||||
|
||||
const adjustWindowHeight = (winName, targetHeight) => {
|
||||
internalBridge.emit('window:adjustWindowHeight', { winName, targetHeight });
|
||||
};
|
||||
|
||||
|
||||
function setupWindowController(windowPool, layoutManager, movementManager) {
|
||||
internalBridge.on('window:requestVisibility', ({ name, visible }) => {
|
||||
handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, visible);
|
||||
});
|
||||
internalBridge.on('window:requestToggleAllWindowsVisibility', ({ targetVisibility }) => {
|
||||
changeAllWindowsVisibility(windowPool, targetVisibility);
|
||||
});
|
||||
internalBridge.on('window:moveToDisplay', ({ displayId }) => {
|
||||
// movementManager.moveToDisplay(displayId);
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const newPosition = layoutManager.calculateNewPositionForDisplay(header, displayId);
|
||||
if (newPosition) {
|
||||
movementManager.animateWindowPosition(header, newPosition, {
|
||||
onComplete: () => updateChildWindowLayouts(true)
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
internalBridge.on('window:moveToEdge', ({ direction }) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const newPosition = layoutManager.calculateEdgePosition(header, direction);
|
||||
movementManager.animateWindowPosition(header, newPosition, {
|
||||
onComplete: () => updateChildWindowLayouts(true)
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
internalBridge.on('window:moveStep', ({ direction }) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const newHeaderPosition = layoutManager.calculateStepMovePosition(header, direction);
|
||||
if (!newHeaderPosition) return;
|
||||
|
||||
const futureHeaderBounds = { ...header.getBounds(), ...newHeaderPosition };
|
||||
const visibleWindows = {};
|
||||
const listenWin = windowPool.get('listen');
|
||||
const askWin = windowPool.get('ask');
|
||||
if (listenWin && !listenWin.isDestroyed() && listenWin.isVisible()) {
|
||||
visibleWindows.listen = true;
|
||||
}
|
||||
if (askWin && !askWin.isDestroyed() && askWin.isVisible()) {
|
||||
visibleWindows.ask = true;
|
||||
}
|
||||
|
||||
const newChildLayout = layoutManager.calculateFeatureWindowLayout(visibleWindows, futureHeaderBounds);
|
||||
|
||||
movementManager.animateWindowPosition(header, newHeaderPosition);
|
||||
movementManager.animateLayout(newChildLayout);
|
||||
}
|
||||
});
|
||||
|
||||
internalBridge.on('window:resizeHeaderWindow', ({ width, height }) => {
|
||||
const header = windowPool.get('header');
|
||||
if (!header || movementManager.isAnimating) return;
|
||||
|
||||
const newHeaderBounds = layoutManager.calculateHeaderResize(header, { width, height });
|
||||
|
||||
const wasResizable = header.isResizable();
|
||||
if (!wasResizable) header.setResizable(true);
|
||||
|
||||
movementManager.animateWindowBounds(header, newHeaderBounds, {
|
||||
onComplete: () => {
|
||||
if (!wasResizable) header.setResizable(false);
|
||||
updateChildWindowLayouts(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
internalBridge.on('window:headerAnimationFinished', (state) => {
|
||||
const header = windowPool.get('header');
|
||||
if (!header || header.isDestroyed()) return;
|
||||
|
||||
if (state === 'hidden') {
|
||||
header.hide();
|
||||
} else if (state === 'visible') {
|
||||
updateChildWindowLayouts(false);
|
||||
}
|
||||
});
|
||||
internalBridge.on('window:getHeaderPosition', (reply) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header && !header.isDestroyed()) {
|
||||
reply(header.getBounds());
|
||||
} else {
|
||||
reply({ x: 0, y: 0, width: 0, height: 0 });
|
||||
}
|
||||
});
|
||||
internalBridge.on('window:moveHeaderTo', ({ newX, newY }) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const newPosition = layoutManager.calculateClampedPosition(header, { x: newX, y: newY });
|
||||
header.setPosition(newPosition.x, newPosition.y);
|
||||
}
|
||||
});
|
||||
internalBridge.on('window:adjustWindowHeight', ({ winName, targetHeight }) => {
|
||||
console.log(`[Layout Debug] adjustWindowHeight: targetHeight=${targetHeight}`);
|
||||
const senderWindow = windowPool.get(winName);
|
||||
if (senderWindow) {
|
||||
const newBounds = layoutManager.calculateWindowHeightAdjustment(senderWindow, targetHeight);
|
||||
|
||||
const wasResizable = senderWindow.isResizable();
|
||||
if (!wasResizable) senderWindow.setResizable(true);
|
||||
|
||||
movementManager.animateWindowBounds(senderWindow, newBounds, {
|
||||
onComplete: () => {
|
||||
if (!wasResizable) senderWindow.setResizable(false);
|
||||
updateChildWindowLayouts(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function changeAllWindowsVisibility(windowPool, targetVisibility) {
|
||||
const header = windowPool.get('header');
|
||||
if (!header) return;
|
||||
|
||||
if (typeof targetVisibility === 'boolean' &&
|
||||
header.isVisible() === targetVisibility) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (header.isVisible()) {
|
||||
lastVisibleWindows.clear();
|
||||
|
||||
windowPool.forEach((win, name) => {
|
||||
if (win && !win.isDestroyed() && win.isVisible()) {
|
||||
lastVisibleWindows.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
lastVisibleWindows.forEach(name => {
|
||||
if (name === 'header') return;
|
||||
const win = windowPool.get(name);
|
||||
if (win && !win.isDestroyed()) win.hide();
|
||||
});
|
||||
header.hide();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisibleWindows.forEach(name => {
|
||||
const win = windowPool.get(name);
|
||||
if (win && !win.isDestroyed())
|
||||
win.show();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Map<string, BrowserWindow>} windowPool
|
||||
* @param {WindowLayoutManager} layoutManager
|
||||
* @param {SmoothMovementManager} movementManager
|
||||
* @param {'listen' | 'ask' | 'settings' | 'shortcut-settings'} name
|
||||
* @param {boolean} shouldBeVisible
|
||||
*/
|
||||
async function handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, shouldBeVisible) {
|
||||
console.log(`[WindowManager] Request: set '${name}' visibility to ${shouldBeVisible}`);
|
||||
const win = windowPool.get(name);
|
||||
|
||||
if (!win || win.isDestroyed()) {
|
||||
console.warn(`[WindowManager] Window '${name}' not found or destroyed.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (name !== 'settings') {
|
||||
const isCurrentlyVisible = win.isVisible();
|
||||
if (isCurrentlyVisible === shouldBeVisible) {
|
||||
console.log(`[WindowManager] Window '${name}' is already in the desired state.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const disableClicks = (selectedWindow) => {
|
||||
for (const [name, win] of windowPool) {
|
||||
if (win !== selectedWindow && !win.isDestroyed()) {
|
||||
win.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const restoreClicks = () => {
|
||||
for (const [, win] of windowPool) {
|
||||
if (!win.isDestroyed()) win.setIgnoreMouseEvents(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (name === 'settings') {
|
||||
if (shouldBeVisible) {
|
||||
// Cancel any pending hide operations
|
||||
if (settingsHideTimer) {
|
||||
clearTimeout(settingsHideTimer);
|
||||
settingsHideTimer = null;
|
||||
}
|
||||
const position = layoutManager.calculateSettingsWindowPosition();
|
||||
if (position) {
|
||||
win.setBounds(position);
|
||||
win.__lockedByButton = true;
|
||||
win.show();
|
||||
win.moveTop();
|
||||
win.setAlwaysOnTop(true);
|
||||
} else {
|
||||
console.warn('[WindowManager] Could not calculate settings window position.');
|
||||
}
|
||||
} else {
|
||||
// Hide after a delay
|
||||
if (settingsHideTimer) {
|
||||
clearTimeout(settingsHideTimer);
|
||||
}
|
||||
settingsHideTimer = setTimeout(() => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.setAlwaysOnTop(false);
|
||||
win.hide();
|
||||
}
|
||||
settingsHideTimer = null;
|
||||
}, 200);
|
||||
|
||||
win.__lockedByButton = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (name === 'shortcut-settings') {
|
||||
if (shouldBeVisible) {
|
||||
// layoutManager.positionShortcutSettingsWindow();
|
||||
const newBounds = layoutManager.calculateShortcutSettingsWindowPosition();
|
||||
if (newBounds) win.setBounds(newBounds);
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
win.setAlwaysOnTop(true, 'screen-saver');
|
||||
} else {
|
||||
win.setAlwaysOnTop(true);
|
||||
}
|
||||
// globalShortcut.unregisterAll();
|
||||
disableClicks(win);
|
||||
win.show();
|
||||
} else {
|
||||
if (process.platform === 'darwin') {
|
||||
win.setAlwaysOnTop(false, 'screen-saver');
|
||||
} else {
|
||||
win.setAlwaysOnTop(false);
|
||||
}
|
||||
restoreClicks();
|
||||
win.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (name === 'listen' || name === 'ask') {
|
||||
const win = windowPool.get(name);
|
||||
const otherName = name === 'listen' ? 'ask' : 'listen';
|
||||
const otherWin = windowPool.get(otherName);
|
||||
const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible();
|
||||
|
||||
const ANIM_OFFSET_X = 50;
|
||||
const ANIM_OFFSET_Y = 20;
|
||||
|
||||
const finalVisibility = {
|
||||
listen: (name === 'listen' && shouldBeVisible) || (otherName === 'listen' && isOtherWinVisible),
|
||||
ask: (name === 'ask' && shouldBeVisible) || (otherName === 'ask' && isOtherWinVisible),
|
||||
};
|
||||
if (!shouldBeVisible) {
|
||||
finalVisibility[name] = false;
|
||||
}
|
||||
|
||||
const targetLayout = layoutManager.calculateFeatureWindowLayout(finalVisibility);
|
||||
|
||||
if (shouldBeVisible) {
|
||||
if (!win) return;
|
||||
const targetBounds = targetLayout[name];
|
||||
if (!targetBounds) return;
|
||||
|
||||
const startPos = { ...targetBounds };
|
||||
if (name === 'listen') startPos.x -= ANIM_OFFSET_X;
|
||||
else if (name === 'ask') startPos.y -= ANIM_OFFSET_Y;
|
||||
|
||||
win.setOpacity(0);
|
||||
win.setBounds(startPos);
|
||||
win.show();
|
||||
|
||||
movementManager.fade(win, { to: 1 });
|
||||
movementManager.animateLayout(targetLayout);
|
||||
|
||||
} else {
|
||||
if (!win || !win.isVisible()) return;
|
||||
|
||||
const currentBounds = win.getBounds();
|
||||
const targetPos = { ...currentBounds };
|
||||
if (name === 'listen') targetPos.x -= ANIM_OFFSET_X;
|
||||
else if (name === 'ask') targetPos.y -= ANIM_OFFSET_Y;
|
||||
|
||||
movementManager.fade(win, { to: 0, onComplete: () => win.hide() });
|
||||
movementManager.animateWindowPosition(win, targetPos);
|
||||
|
||||
// 다른 창들도 새 레이아웃으로 애니메이션
|
||||
const otherWindowsLayout = { ...targetLayout };
|
||||
delete otherWindowsLayout[name];
|
||||
movementManager.animateLayout(otherWindowsLayout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let movementManager = null;
|
||||
|
||||
const setContentProtection = (status) => {
|
||||
isContentProtectionOn = status;
|
||||
@ -119,108 +423,6 @@ const toggleContentProtection = () => {
|
||||
return newStatus;
|
||||
};
|
||||
|
||||
const resizeHeaderWindow = ({ width, height }) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
console.log(`[WindowManager] Resize request: ${width}x${height}`);
|
||||
|
||||
if (movementManager && movementManager.isAnimating) {
|
||||
console.log('[WindowManager] Skipping resize during animation');
|
||||
return { success: false, error: 'Cannot resize during animation' };
|
||||
}
|
||||
|
||||
const currentBounds = header.getBounds();
|
||||
console.log(`[WindowManager] Current bounds: ${currentBounds.width}x${currentBounds.height} at (${currentBounds.x}, ${currentBounds.y})`);
|
||||
|
||||
if (currentBounds.width === width && currentBounds.height === height) {
|
||||
console.log('[WindowManager] Already at target size, skipping resize');
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const wasResizable = header.isResizable();
|
||||
if (!wasResizable) {
|
||||
header.setResizable(true);
|
||||
}
|
||||
|
||||
const centerX = currentBounds.x + currentBounds.width / 2;
|
||||
const newX = Math.round(centerX - width / 2);
|
||||
|
||||
const display = getCurrentDisplay(header);
|
||||
const { x: workAreaX, width: workAreaWidth } = display.workArea;
|
||||
|
||||
const clampedX = Math.max(workAreaX, Math.min(workAreaX + workAreaWidth - width, newX));
|
||||
|
||||
header.setBounds({ x: clampedX, y: currentBounds.y, width, height });
|
||||
|
||||
if (!wasResizable) {
|
||||
header.setResizable(false);
|
||||
}
|
||||
|
||||
if (updateLayout) {
|
||||
updateLayout();
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
return { success: false, error: 'Header window not found' };
|
||||
};
|
||||
|
||||
const openShortcutEditor = () => {
|
||||
const header = windowPool.get('header');
|
||||
if (!header) return;
|
||||
globalShortcut.unregisterAll();
|
||||
createFeatureWindows(header, 'shortcut-settings');
|
||||
};
|
||||
|
||||
const showSettingsWindow = (bounds) => {
|
||||
if (!bounds) return;
|
||||
const win = windowPool.get('settings');
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (settingsHideTimer) {
|
||||
clearTimeout(settingsHideTimer);
|
||||
settingsHideTimer = null;
|
||||
}
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const hideSettingsWindow = () => {
|
||||
const window = windowPool.get("settings");
|
||||
if (window && !window.isDestroyed()) {
|
||||
if (settingsHideTimer) {
|
||||
clearTimeout(settingsHideTimer);
|
||||
}
|
||||
settingsHideTimer = setTimeout(() => {
|
||||
if (window && !window.isDestroyed()) {
|
||||
window.setAlwaysOnTop(false);
|
||||
window.hide();
|
||||
}
|
||||
settingsHideTimer = null;
|
||||
}, 200);
|
||||
|
||||
window.__lockedByButton = false;
|
||||
}
|
||||
};
|
||||
|
||||
const cancelHideSettingsWindow = () => {
|
||||
if (settingsHideTimer) {
|
||||
clearTimeout(settingsHideTimer);
|
||||
settingsHideTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const openLoginPage = () => {
|
||||
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
|
||||
@ -229,12 +431,6 @@ const openLoginPage = () => {
|
||||
console.log('Opening personalization page:', personalizeUrl);
|
||||
};
|
||||
|
||||
const moveWindowStep = (direction) => {
|
||||
if (movementManager) {
|
||||
movementManager.moveStep(direction);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function createFeatureWindows(header, namesToCreate) {
|
||||
// if (windowPool.has('listen')) return;
|
||||
@ -248,7 +444,7 @@ function createFeatureWindows(header, namesToCreate) {
|
||||
hasShadow: false,
|
||||
skipTaskbar: true,
|
||||
hiddenInMissionControl: true,
|
||||
resizable: true,
|
||||
resizable: false,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
@ -322,12 +518,6 @@ function createFeatureWindows(header, namesToCreate) {
|
||||
if (!app.isPackaged) {
|
||||
ask.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
|
||||
ask.on('closed', () => {
|
||||
console.log('[WindowManager] Ask window closed, removing from pool.');
|
||||
windowPool.delete('ask');
|
||||
});
|
||||
|
||||
windowPool.set('ask', ask);
|
||||
break;
|
||||
}
|
||||
@ -369,7 +559,7 @@ function createFeatureWindows(header, namesToCreate) {
|
||||
case 'shortcut-settings': {
|
||||
const shortcutEditor = new BrowserWindow({
|
||||
...commonChildOptions,
|
||||
width: 420,
|
||||
width: 353,
|
||||
height: 720,
|
||||
modal: false,
|
||||
parent: undefined,
|
||||
@ -377,37 +567,12 @@ function createFeatureWindows(header, namesToCreate) {
|
||||
titleBarOverlay: false,
|
||||
});
|
||||
|
||||
shortcutEditor.setContentProtection(isContentProtectionOn);
|
||||
shortcutEditor.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
|
||||
if (process.platform === 'darwin') {
|
||||
shortcutEditor.setAlwaysOnTop(true, 'screen-saver');
|
||||
} else {
|
||||
shortcutEditor.setAlwaysOnTop(true);
|
||||
shortcutEditor.setWindowButtonVisibility(false);
|
||||
}
|
||||
|
||||
/* ──────────[ ① 다른 창 클릭 차단 ]────────── */
|
||||
const disableClicks = () => {
|
||||
for (const [name, win] of windowPool) {
|
||||
if (win !== shortcutEditor && !win.isDestroyed()) {
|
||||
win.setIgnoreMouseEvents(true, { forward: true });
|
||||
}
|
||||
}
|
||||
};
|
||||
const restoreClicks = () => {
|
||||
for (const [, win] of windowPool) {
|
||||
if (!win.isDestroyed()) win.setIgnoreMouseEvents(false);
|
||||
}
|
||||
};
|
||||
|
||||
const header = windowPool.get('header');
|
||||
if (header && !header.isDestroyed()) {
|
||||
const { x, y, width } = header.getBounds();
|
||||
shortcutEditor.setBounds({ x, y, width });
|
||||
}
|
||||
|
||||
shortcutEditor.once('ready-to-show', () => {
|
||||
disableClicks();
|
||||
shortcutEditor.show();
|
||||
});
|
||||
|
||||
const loadOptions = { query: { view: 'shortcut-settings' } };
|
||||
if (!shouldUseLiquidGlass) {
|
||||
shortcutEditor.loadFile(path.join(__dirname, '../ui/app/content.html'), loadOptions);
|
||||
@ -422,22 +587,10 @@ function createFeatureWindows(header, namesToCreate) {
|
||||
});
|
||||
}
|
||||
|
||||
shortcutEditor.on('closed', () => {
|
||||
restoreClicks();
|
||||
windowPool.delete('shortcut-settings');
|
||||
console.log('[Shortcuts] Re-enabled after editing.');
|
||||
shortcutsService.registerShortcuts();
|
||||
});
|
||||
|
||||
shortcutEditor.webContents.once('dom-ready', async () => {
|
||||
const keybinds = await shortcutsService.loadKeybinds();
|
||||
shortcutEditor.webContents.send('load-shortcuts', keybinds);
|
||||
});
|
||||
|
||||
windowPool.set('shortcut-settings', shortcutEditor);
|
||||
if (!app.isPackaged) {
|
||||
shortcutEditor.webContents.openDevTools({ mode: 'detach' });
|
||||
}
|
||||
windowPool.set('shortcut-settings', shortcutEditor);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@ -451,6 +604,7 @@ function createFeatureWindows(header, namesToCreate) {
|
||||
createFeatureWindow('listen');
|
||||
createFeatureWindow('ask');
|
||||
createFeatureWindow('settings');
|
||||
createFeatureWindow('shortcut-settings');
|
||||
}
|
||||
}
|
||||
|
||||
@ -481,51 +635,17 @@ function getCurrentDisplay(window) {
|
||||
return screen.getDisplayNearestPoint(windowCenter);
|
||||
}
|
||||
|
||||
function getDisplayById(displayId) {
|
||||
const displays = screen.getAllDisplays();
|
||||
return displays.find(d => d.id === displayId) || screen.getPrimaryDisplay();
|
||||
}
|
||||
|
||||
|
||||
|
||||
function toggleAllWindowsVisibility() {
|
||||
const header = windowPool.get('header');
|
||||
if (!header) return;
|
||||
|
||||
if (header.isVisible()) {
|
||||
lastVisibleWindows.clear();
|
||||
|
||||
windowPool.forEach((win, name) => {
|
||||
if (win && !win.isDestroyed() && win.isVisible()) {
|
||||
lastVisibleWindows.add(name);
|
||||
}
|
||||
});
|
||||
|
||||
lastVisibleWindows.forEach(name => {
|
||||
if (name === 'header') return;
|
||||
const win = windowPool.get(name);
|
||||
if (win && !win.isDestroyed()) win.hide();
|
||||
});
|
||||
header.hide();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
lastVisibleWindows.forEach(name => {
|
||||
const win = windowPool.get(name);
|
||||
if (win && !win.isDestroyed())
|
||||
win.show();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function createWindows() {
|
||||
const HEADER_HEIGHT = 47;
|
||||
const DEFAULT_WINDOW_WIDTH = 353;
|
||||
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
const { y: workAreaY, width: screenWidth } = primaryDisplay.workArea;
|
||||
|
||||
const initialX = Math.round((screenWidth - DEFAULT_WINDOW_WIDTH) / 2);
|
||||
const initialY = workAreaY + 21;
|
||||
movementManager = new SmoothMovementManager(windowPool, getDisplayById, getCurrentDisplay, updateLayout);
|
||||
|
||||
const header = new BrowserWindow({
|
||||
width: DEFAULT_WINDOW_WIDTH,
|
||||
@ -535,6 +655,7 @@ function createWindows() {
|
||||
frame: false,
|
||||
transparent: true,
|
||||
vibrancy: false,
|
||||
hasShadow: false,
|
||||
alwaysOnTop: true,
|
||||
skipTaskbar: true,
|
||||
hiddenInMissionControl: true,
|
||||
@ -575,15 +696,24 @@ function createWindows() {
|
||||
});
|
||||
}
|
||||
windowPool.set('header', header);
|
||||
header.on('moved', updateLayout);
|
||||
layoutManager = new WindowLayoutManager(windowPool);
|
||||
movementManager = new SmoothMovementManager(windowPool);
|
||||
|
||||
|
||||
header.on('moved', () => {
|
||||
if (movementManager.isAnimating) {
|
||||
return;
|
||||
}
|
||||
updateChildWindowLayouts(false);
|
||||
});
|
||||
|
||||
header.webContents.once('dom-ready', () => {
|
||||
shortcutsService.initialize(movementManager, windowPool);
|
||||
shortcutsService.initialize(windowPool);
|
||||
shortcutsService.registerShortcuts();
|
||||
});
|
||||
|
||||
setupIpcHandlers(movementManager);
|
||||
setupIpcHandlers(windowPool, layoutManager);
|
||||
setupWindowController(windowPool, layoutManager, movementManager);
|
||||
|
||||
if (currentHeaderState === 'main') {
|
||||
createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']);
|
||||
@ -614,16 +744,13 @@ function createWindows() {
|
||||
}
|
||||
});
|
||||
|
||||
header.on('resize', () => {
|
||||
console.log('[WindowManager] Header resize event triggered');
|
||||
updateLayout();
|
||||
});
|
||||
header.on('resize', () => updateChildWindowLayouts(false));
|
||||
|
||||
return windowPool;
|
||||
}
|
||||
|
||||
function setupIpcHandlers(movementManager) {
|
||||
// quit-application handler moved to windowBridge.js to avoid duplication
|
||||
|
||||
function setupIpcHandlers(windowPool, layoutManager) {
|
||||
screen.on('display-added', (event, newDisplay) => {
|
||||
console.log('[Display] New display added:', newDisplay.id);
|
||||
});
|
||||
@ -631,18 +758,25 @@ function setupIpcHandlers(movementManager) {
|
||||
screen.on('display-removed', (event, oldDisplay) => {
|
||||
console.log('[Display] Display removed:', oldDisplay.id);
|
||||
const header = windowPool.get('header');
|
||||
|
||||
if (header && getCurrentDisplay(header).id === oldDisplay.id) {
|
||||
const primaryDisplay = screen.getPrimaryDisplay();
|
||||
movementManager.moveToDisplay(primaryDisplay.id);
|
||||
const newPosition = layoutManager.calculateNewPositionForDisplay(header, primaryDisplay.id);
|
||||
if (newPosition) {
|
||||
// 복구 상황이므로 애니메이션 없이 즉시 이동
|
||||
header.setPosition(newPosition.x, newPosition.y, false);
|
||||
updateChildWindowLayouts(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
screen.on('display-metrics-changed', (event, display, changedMetrics) => {
|
||||
// console.log('[Display] Display metrics changed:', display.id, changedMetrics);
|
||||
updateLayout();
|
||||
// 레이아웃 업데이트 함수를 새 버전으로 호출
|
||||
updateChildWindowLayouts(false);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const handleHeaderStateChanged = (state) => {
|
||||
console.log(`[WindowManager] Header state changed to: ${state}`);
|
||||
currentHeaderState = state;
|
||||
@ -655,163 +789,21 @@ const handleHeaderStateChanged = (state) => {
|
||||
internalBridge.emit('reregister-shortcuts');
|
||||
};
|
||||
|
||||
const handleHeaderAnimationFinished = (state) => {
|
||||
const header = windowPool.get('header');
|
||||
if (!header || header.isDestroyed()) return;
|
||||
|
||||
if (state === 'hidden') {
|
||||
header.hide();
|
||||
console.log('[WindowManager] Header hidden after animation.');
|
||||
} else if (state === 'visible') {
|
||||
console.log('[WindowManager] Header shown after animation.');
|
||||
updateLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const getHeaderPosition = () => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const [x, y] = header.getPosition();
|
||||
return { x, y };
|
||||
}
|
||||
return { x: 0, y: 0 };
|
||||
};
|
||||
|
||||
const moveHeader = (newX, newY) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const currentY = newY !== undefined ? newY : header.getBounds().y;
|
||||
header.setPosition(newX, currentY, false);
|
||||
updateLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const moveHeaderTo = (newX, newY) => {
|
||||
const header = windowPool.get('header');
|
||||
if (header) {
|
||||
const targetDisplay = screen.getDisplayNearestPoint({ x: newX, y: newY });
|
||||
const { x: workAreaX, y: workAreaY, width, height } = targetDisplay.workArea;
|
||||
const headerBounds = header.getBounds();
|
||||
|
||||
let clampedX = newX;
|
||||
let clampedY = newY;
|
||||
|
||||
if (newX < workAreaX) {
|
||||
clampedX = workAreaX;
|
||||
} else if (newX + headerBounds.width > workAreaX + width) {
|
||||
clampedX = workAreaX + width - headerBounds.width;
|
||||
}
|
||||
|
||||
if (newY < workAreaY) {
|
||||
clampedY = workAreaY;
|
||||
} else if (newY + headerBounds.height > workAreaY + height) {
|
||||
clampedY = workAreaY + height - headerBounds.height;
|
||||
}
|
||||
|
||||
header.setPosition(clampedX, clampedY, false);
|
||||
updateLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const adjustWindowHeight = (sender, targetHeight) => {
|
||||
const senderWindow = BrowserWindow.fromWebContents(sender);
|
||||
if (senderWindow) {
|
||||
const wasResizable = senderWindow.isResizable();
|
||||
if (!wasResizable) {
|
||||
senderWindow.setResizable(true);
|
||||
}
|
||||
|
||||
const currentBounds = senderWindow.getBounds();
|
||||
const minHeight = senderWindow.getMinimumSize()[1];
|
||||
const maxHeight = senderWindow.getMaximumSize()[1];
|
||||
|
||||
let adjustedHeight;
|
||||
if (maxHeight === 0) {
|
||||
adjustedHeight = Math.max(minHeight, targetHeight);
|
||||
} else {
|
||||
adjustedHeight = Math.max(minHeight, Math.min(maxHeight, targetHeight));
|
||||
}
|
||||
|
||||
senderWindow.setSize(currentBounds.width, adjustedHeight, false);
|
||||
|
||||
if (!wasResizable) {
|
||||
senderWindow.setResizable(false);
|
||||
}
|
||||
|
||||
updateLayout();
|
||||
}
|
||||
};
|
||||
|
||||
const handleAnimationFinished = (sender) => {
|
||||
const win = BrowserWindow.fromWebContents(sender);
|
||||
if (win && !win.isDestroyed()) {
|
||||
console.log(`[WindowManager] Hiding window after animation.`);
|
||||
win.hide();
|
||||
}
|
||||
};
|
||||
|
||||
const closeAskWindow = () => {
|
||||
const askWindow = windowPool.get('ask');
|
||||
if (askWindow) {
|
||||
askWindow.webContents.send('window-hide-animation');
|
||||
}
|
||||
};
|
||||
|
||||
async function ensureAskWindowVisible() {
|
||||
if (currentHeaderState !== 'main') {
|
||||
console.log('[WindowManager] Not in main state, skipping ensureAskWindowVisible');
|
||||
return;
|
||||
}
|
||||
|
||||
let askWindow = windowPool.get('ask');
|
||||
|
||||
if (!askWindow || askWindow.isDestroyed()) {
|
||||
console.log('[WindowManager] Ask window not found, creating new one');
|
||||
createFeatureWindows(windowPool.get('header'), 'ask');
|
||||
askWindow = windowPool.get('ask');
|
||||
}
|
||||
|
||||
if (!askWindow.isVisible()) {
|
||||
console.log('[WindowManager] Showing hidden Ask window');
|
||||
askWindow.show();
|
||||
updateLayout();
|
||||
askWindow.webContents.send('window-show-animation');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
//////// after_modelStateService ////////
|
||||
|
||||
const closeWindow = (windowName) => {
|
||||
const win = windowPool.get(windowName);
|
||||
if (win && !win.isDestroyed()) {
|
||||
win.close();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateLayout,
|
||||
createWindows,
|
||||
windowPool,
|
||||
fixedYPosition,
|
||||
toggleContentProtection,
|
||||
resizeHeaderWindow,
|
||||
getContentProtectionStatus,
|
||||
openShortcutEditor,
|
||||
showSettingsWindow,
|
||||
hideSettingsWindow,
|
||||
cancelHideSettingsWindow,
|
||||
openLoginPage,
|
||||
moveWindowStep,
|
||||
closeWindow,
|
||||
toggleAllWindowsVisibility,
|
||||
handleHeaderStateChanged,
|
||||
handleHeaderAnimationFinished,
|
||||
getHeaderPosition,
|
||||
moveHeader,
|
||||
moveHeaderTo,
|
||||
adjustWindowHeight,
|
||||
handleAnimationFinished,
|
||||
closeAskWindow,
|
||||
ensureAskWindowVisible,
|
||||
};
|
Loading…
x
Reference in New Issue
Block a user