Compare commits

...

30 Commits

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

* Update aec submodule

* folder structure refactor

* fixing ask logic

* resolve import err

* fix askview

* fix header content html path

* fix systemaudiodump path

* centralized ask logic

* delete legacy code

* change askservice to class

* settingsService facade

* fix getCurrentModelInfo

* common service ipc moved to featureBridge

* featureBridge init

* ui fix

* add featureBridge func for listenservice

* fix preload conflict

* shortcuts seperated

* refactor ask

* transfer roles from askview to askservice

* modifying windowBridge

* delete legacy ask code

* retrieve conversation history for askserice

* fix legacy code

* shortcut moved

* change naming for featurebridge

* screenshot moved from windowManager

* rough refactor done

---------

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

View File

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

2
aec

@ -1 +1 @@
Subproject commit 3be088c6cff8021c74eca714150e68e2cc74bee0
Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163

View File

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

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

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

View File

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

78
package-lock.json generated
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,310 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" />
<title>Pickle Glass Content</title>
<style>
:root {
--background-transparent: transparent;
--text-color: #e5e5e7;
--border-color: rgba(255, 255, 255, 0.2);
--header-background: rgba(0, 0, 0, 0.8);
--header-actions-color: rgba(255, 255, 255, 0.6);
--main-content-background: rgba(0, 0, 0, 0.8);
--button-background: rgba(0, 0, 0, 0.5);
--button-border: rgba(255, 255, 255, 0.1);
--icon-button-color: rgb(229, 229, 231);
--hover-background: rgba(255, 255, 255, 0.1);
--input-background: rgba(0, 0, 0, 0.3);
--placeholder-color: rgba(255, 255, 255, 0.4);
--focus-border-color: #007aff;
--focus-box-shadow: rgba(0, 122, 255, 0.2);
--input-focus-background: rgba(0, 0, 0, 0.5);
--scrollbar-track: rgba(0, 0, 0, 0.2);
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
--preview-video-background: rgba(0, 0, 0, 0.9);
--preview-video-border: rgba(255, 255, 255, 0.15);
--option-label-color: rgba(255, 255, 255, 0.8);
--screen-option-background: rgba(0, 0, 0, 0.4);
--screen-option-hover-background: rgba(0, 0, 0, 0.6);
--screen-option-selected-background: rgba(0, 122, 255, 0.15);
--screen-option-text: rgba(255, 255, 255, 0.7);
--description-color: rgba(255, 255, 255, 0.6);
--start-button-background: white;
--start-button-color: black;
--start-button-border: white;
--start-button-hover-background: rgba(255, 255, 255, 0.8);
--start-button-hover-border: rgba(0, 0, 0, 0.2);
--text-input-button-background: #007aff;
--text-input-button-hover: #0056b3;
--link-color: #007aff;
--key-background: rgba(255, 255, 255, 0.1);
--scrollbar-background: rgba(0, 0, 0, 0.4);
/* Layout-specific variables */
--header-padding: 10px 20px;
--header-font-size: 16px;
--header-gap: 12px;
--header-button-padding: 8px 16px;
--header-icon-padding: 8px;
--header-font-size-small: 13px;
--main-content-padding: 20px;
--main-content-margin-top: 10px;
--icon-size: 24px;
--border-radius: 7px;
--content-border-radius: 7px;
}
/* Compact layout styles */
:root.compact-layout {
--header-padding: 6px 12px;
--header-font-size: 13px;
--header-gap: 6px;
--header-button-padding: 4px 8px;
--header-icon-padding: 4px;
--header-font-size-small: 10px;
--main-content-padding: 10px;
--main-content-margin-top: 2px;
--icon-size: 16px;
--border-radius: 4px;
--content-border-radius: 4px;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
overflow: hidden;
background: transparent;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
pickle-glass-app {
display: block;
width: 100%;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
transform-origin: center center;
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>
<script src="../assets/marked-4.3.0.min.js"></script>
<script type="module" src="../../public/build/content.js"></script>
<pickle-glass-app id="pickle-glass"></pickle-glass-app>
<script>
window.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('pickle-glass');
if (window.require) {
const { ipcRenderer } = window.require('electron');
// --- 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.`);
ipcRenderer.send('animation-finished');
// 완료 후 애니메이션 클래스 정리
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');
}
});
ipcRenderer.on('window-show-animation', () => {
console.log('Starting window show animation');
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
app.classList.add('window-sliding-down');
});
ipcRenderer.on('window-hide-animation', () => {
console.log('Starting window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('window-sliding-up');
});
ipcRenderer.on('settings-window-hide-animation', () => {
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 ---
ipcRenderer.on('listen-window-move-to-center', () => {
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);
});
ipcRenderer.on('listen-window-move-to-left', () => {
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>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body>
</html>

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

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

View File

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

View File

@ -0,0 +1,34 @@
// src/bridge/windowBridge.js
const { ipcMain, 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.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.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-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
ipcMain.handle('adjust-window-height', (event, { winName, height }) => windowManager.adjustWindowHeight(winName, height));
},
notifyFocusChange(win, isFocused) {
win.webContents.send('window:focus-change', isFocused);
}
};

View File

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

View File

@ -1,46 +0,0 @@
const DOWNLOAD_CHECKSUMS = {
ollama: {
dmg: {
url: 'https://ollama.com/download/Ollama.dmg',
sha256: null // To be updated with actual checksum
},
exe: {
url: 'https://ollama.com/download/OllamaSetup.exe',
sha256: null // To be updated with actual checksum
}
},
whisper: {
models: {
'whisper-tiny': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
},
'whisper-base': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
},
'whisper-small': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
},
'whisper-medium': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208'
}
},
binaries: {
'v1.7.6': {
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
},
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
}
}
}
}
};
module.exports = { DOWNLOAD_CHECKSUMS };

View File

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

View File

@ -1,65 +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 providerSettings repository');
}
const user = authService.getCurrentUser();
return user.isLoggedIn ? firebaseRepository : sqliteRepository;
}
const providerSettingsRepositoryAdapter = {
// Core CRUD operations
async getByProvider(provider) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.getByProvider(uid, provider);
},
async getAllByUid() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.getAllByUid(uid);
},
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);
},
async remove(provider) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.remove(uid, provider);
},
async removeAllByUid() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.removeAllByUid(uid);
}
};
module.exports = {
...providerSettingsRepositoryAdapter,
setAuthService
};

View File

@ -1,62 +0,0 @@
const sqliteClient = require('../../services/sqliteClient');
function getByProvider(uid, provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
return stmt.get(uid, provider) || null;
}
function getAllByUid(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
return stmt.all(uid);
}
function upsert(uid, provider, settings) {
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
api_key = excluded.api_key,
selected_llm_model = excluded.selected_llm_model,
selected_stt_model = excluded.selected_stt_model,
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,
settings.created_at || Date.now(),
settings.updated_at
);
return { changes: result.changes };
}
function remove(uid, provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?');
const result = stmt.run(uid, provider);
return { changes: result.changes };
}
function removeAllByUid(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?');
const result = stmt.run(uid);
return { changes: result.changes };
}
module.exports = {
getByProvider,
getAllByUid,
upsert,
remove,
removeAllByUid
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,581 +0,0 @@
const Store = require('electron-store');
const fetch = require('node-fetch');
const { ipcMain, webContents } = require('electron');
const { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections');
class ModelStateService {
constructor(authService) {
this.authService = authService;
this.store = new Store({ name: 'pickle-glass-model-state' });
this.state = {};
this.hasMigrated = false;
// Set auth service for repositories
providerSettingsRepository.setAuthService(authService);
userModelSelectionsRepository.setAuthService(authService);
}
async initialize() {
console.log('[ModelStateService] Initializing...');
await this._loadStateForCurrentUser();
this.setupIpcHandlers();
console.log('[ModelStateService] Initialization complete');
}
_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})`);
}
_autoSelectAvailableModels() {
console.log('[ModelStateService] Running auto-selection for models...');
const types = ['llm', 'stt'];
types.forEach(type => {
const currentModelId = this.state.selectedModels[type];
let isCurrentModelValid = false;
if (currentModelId) {
const provider = this.getProviderForModel(type, currentModelId);
const apiKey = this.getApiKey(provider);
// For Ollama, 'local' is a valid API key
if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) {
isCurrentModelValid = true;
}
}
if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`);
const availableModels = 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);
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'})`);
} 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;
}
}
}
// Load global model selections
const modelSelections = await userModelSelectionsRepository.get();
const selectedModels = {
llm: modelSelections?.selected_llm_model || null,
stt: modelSelections?.selected_stt_model || null
};
this.state = {
apiKeys,
selectedModels
};
console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
} catch (error) {
console.error('[ModelStateService] Failed to load state from database:', error);
// Fall back to default state
const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => {
acc[key] = null;
return acc;
}, {});
this.state = {
apiKeys: initialApiKeys,
selectedModels: { llm: null, stt: null },
};
}
}
async _loadStateForCurrentUser() {
const userId = this.authService.getCurrentUserId();
// Initialize encryption service for current user
await encryptionService.initializeKey(userId);
// Try to load from database first
await this._loadStateFromDatabase();
// Check if we need to migrate from electron-store
const legacyData = this.store.get(`users.${userId}`, null);
if (legacyData && !this.hasMigrated) {
await this._migrateFromElectronStore();
// Reload state after migration
await this._loadStateFromDatabase();
this.hasMigrated = true;
}
this._autoSelectAvailableModels();
await this._saveState();
this._logCurrentSelection();
}
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.`);
} else {
console.log(`[ModelStateService] API key for ${provider} is invalid: ${result.error}`);
}
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();
}
setApiKey(provider, key) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = key;
this._saveState();
return true;
}
return false;
}
getApiKey(provider) {
return this.state.apiKeys[provider];
}
getAllApiKeys() {
const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys;
return displayKeys;
}
removeApiKey(provider) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = null;
const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm);
if (llmProvider === provider) this.state.selectedModels.llm = null;
const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt);
if (sttProvider === provider) this.state.selectedModels.stt = null;
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
return true;
}
return false;
}
getProviderForModel(type, modelId) {
if (!modelId) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models.some(m => m.id === modelId)) {
return providerId;
}
}
// 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);
}
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
}
areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true;
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'ollama') {
// Ollama uses dynamic models, so just check if configured (has 'local' key)
return key === 'local';
}
if (provider === 'whisper') {
// Whisper doesn't support LLM
return false;
}
return key && PROVIDERS[provider]?.llmModels.length > 0;
});
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'whisper') {
// Whisper has static model list and supports STT
return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0;
}
if (provider === 'ollama') {
// Ollama doesn't support STT yet
return false;
}
return key && PROVIDERS[provider]?.sttModels.length > 0;
});
const result = hasLlmKey && hasSttKey;
console.log(`[ModelStateService] areProvidersConfigured: LLM=${hasLlmKey}, STT=${hasSttKey}, result=${result}`);
return result;
}
hasValidApiKey() {
if (this.isLoggedInWithFirebase()) return true;
// Check if any provider has a valid API key
return Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'ollama' || provider === 'whisper') {
return key === 'local';
}
return key && key.trim().length > 0;
});
}
getAvailableModels(type) {
const available = [];
const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
if (key && PROVIDERS[providerId]?.[modelList]) {
available.push(...PROVIDERS[providerId][modelList]);
}
});
return [...new Map(available.map(item => [item.id, item])).values()];
}
getSelectedModels() {
return this.state.selectedModels;
}
setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(type, modelId);
if (provider && this.state.apiKeys[provider]) {
const previousModel = this.state.selectedModels[type];
this.state.selectedModels[type] = modelId;
this._saveState();
// Auto warm-up for Ollama LLM models when changed
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
this._autoWarmUpOllamaModel(modelId, previousModel);
}
return true;
}
return false;
}
/**
* Auto warm-up Ollama model when LLM selection changes
* @private
* @param {string} newModelId - The newly selected model
* @param {string} previousModelId - The previously selected model
*/
async _autoWarmUpOllamaModel(newModelId, previousModelId) {
try {
console.log(`[ModelStateService] 🔥 LLM model changed: ${previousModelId || 'None'}${newModelId}, triggering warm-up`);
// Get Ollama service if available
const ollamaService = require('./ollamaService');
if (!ollamaService) {
console.log('[ModelStateService] OllamaService not available for auto warm-up');
return;
}
// Delay warm-up slightly to allow UI to update first
setTimeout(async () => {
try {
console.log(`[ModelStateService] Starting background warm-up for: ${newModelId}`);
const success = await ollamaService.warmUpModel(newModelId);
if (success) {
console.log(`[ModelStateService] ✅ Successfully warmed up model: ${newModelId}`);
} else {
console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`);
}
} catch (error) {
console.log(`[ModelStateService] 🚫 Error during auto warm-up for ${newModelId}:`, error.message);
}
}, 500); // 500ms delay
} catch (error) {
console.error('[ModelStateService] Error in auto warm-up setup:', error);
}
}
/**
*
* @param {('llm' | 'stt')} type
* @returns {{provider: string, model: string, apiKey: string} | null}
*/
getCurrentModelInfo(type) {
this._logCurrentSelection();
const model = this.state.selectedModels[type];
if (!model) {
return null;
}
const provider = this.getProviderForModel(type, model);
if (!provider) {
return null;
}
const apiKey = this.getApiKey(provider);
return { provider, model, apiKey };
}
setupIpcHandlers() {
ipcMain.handle('model:validate-key', async (e, { provider, key }) => {
const result = await this.validateApiKey(provider, key);
if (result.success) {
// Use 'local' as placeholder for local services
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
this.setApiKey(provider, finalKey);
// After setting the key, auto-select models
this._autoSelectAvailableModels();
this._saveState(); // Ensure state is saved after model selection
}
return result;
});
ipcMain.handle('model:get-all-keys', () => this.getAllApiKeys());
ipcMain.handle('model:set-api-key', async (e, { provider, key }) => {
const success = this.setApiKey(provider, key);
if (success) {
this._autoSelectAvailableModels();
await this._saveState();
}
return success;
});
ipcMain.handle('model:remove-api-key', async (e, { provider }) => {
const success = this.removeApiKey(provider);
if (success) {
const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) {
webContents.getAllWebContents().forEach(wc => {
wc.send('force-show-apikey-header');
});
}
}
return success;
});
ipcMain.handle('model:get-selected-models', () => this.getSelectedModels());
ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => this.setSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', (e, { type }) => this.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', () => this.areProvidersConfigured());
ipcMain.handle('model:get-current-model-info', (e, { type }) => this.getCurrentModelInfo(type));
ipcMain.handle('model:get-provider-config', () => {
const serializableProviders = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
serializableProviders[key] = rest;
}
return serializableProviders;
});
}
}
module.exports = ModelStateService;

View File

@ -1,809 +0,0 @@
const { spawn } = require('child_process');
const { promisify } = require('util');
const fetch = require('node-fetch');
const path = require('path');
const fs = require('fs').promises;
const { app } = require('electron');
const LocalAIServiceBase = require('./localAIServiceBase');
const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
class OllamaService extends LocalAIServiceBase {
constructor() {
super('OllamaService');
this.baseUrl = 'http://localhost:11434';
this.warmingModels = new Map();
this.warmedModels = new Set();
this.lastWarmUpAttempt = new Map();
// Request management system
this.activeRequests = new Map();
this.requestTimeouts = new Map();
this.healthStatus = {
lastHealthCheck: 0,
consecutive_failures: 0,
is_circuit_open: false
};
// Configuration
this.requestTimeout = 8000; // 8s for health checks
this.warmupTimeout = 15000; // 15s for model warmup
this.healthCheckInterval = 60000; // 1min between health checks
this.circuitBreakerThreshold = 3;
this.circuitBreakerCooldown = 30000; // 30s
// Supported models are determined dynamically from installed models
this.supportedModels = {};
// Start health monitoring
this._startHealthMonitoring();
}
getOllamaCliPath() {
if (this.getPlatform() === 'darwin') {
return '/Applications/Ollama.app/Contents/Resources/ollama';
}
return 'ollama';
}
/**
* Professional request management with AbortController-based cancellation
*/
async _makeRequest(url, options = {}, operationType = 'default') {
const requestId = `${operationType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Circuit breaker check
if (this._isCircuitOpen()) {
throw new Error('Service temporarily unavailable (circuit breaker open)');
}
// Request deduplication for health checks
if (operationType === 'health' && this.activeRequests.has('health')) {
console.log('[OllamaService] Health check already in progress, returning existing promise');
return this.activeRequests.get('health');
}
const controller = new AbortController();
const timeout = options.timeout || this.requestTimeout;
// Set up timeout mechanism
const timeoutId = setTimeout(() => {
controller.abort();
this.activeRequests.delete(requestId);
this._recordFailure();
}, timeout);
this.requestTimeouts.set(requestId, timeoutId);
const requestPromise = this._executeRequest(url, {
...options,
signal: controller.signal
}, requestId);
// Store active request for deduplication and cleanup
this.activeRequests.set(operationType === 'health' ? 'health' : requestId, requestPromise);
try {
const result = await requestPromise;
this._recordSuccess();
return result;
} catch (error) {
this._recordFailure();
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
this.requestTimeouts.delete(requestId);
this.activeRequests.delete(operationType === 'health' ? 'health' : requestId);
}
}
async _executeRequest(url, options, requestId) {
try {
console.log(`[OllamaService] Executing request ${requestId} to ${url}`);
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
console.error(`[OllamaService] Request ${requestId} failed:`, error.message);
throw error;
}
}
_isCircuitOpen() {
if (!this.healthStatus.is_circuit_open) return false;
// Check if cooldown period has passed
const now = Date.now();
if (now - this.healthStatus.lastHealthCheck > this.circuitBreakerCooldown) {
console.log('[OllamaService] Circuit breaker cooldown expired, attempting recovery');
this.healthStatus.is_circuit_open = false;
this.healthStatus.consecutive_failures = 0;
return false;
}
return true;
}
_recordSuccess() {
this.healthStatus.consecutive_failures = 0;
this.healthStatus.is_circuit_open = false;
this.healthStatus.lastHealthCheck = Date.now();
}
_recordFailure() {
this.healthStatus.consecutive_failures++;
this.healthStatus.lastHealthCheck = Date.now();
if (this.healthStatus.consecutive_failures >= this.circuitBreakerThreshold) {
console.warn(`[OllamaService] Circuit breaker opened after ${this.healthStatus.consecutive_failures} failures`);
this.healthStatus.is_circuit_open = true;
}
}
_startHealthMonitoring() {
// Passive health monitoring - only when requests are made
console.log('[OllamaService] Health monitoring system initialized');
}
/**
* Cleanup all active requests and resources
*/
_cleanup() {
console.log(`[OllamaService] Cleaning up ${this.activeRequests.size} active requests`);
// Cancel all active requests
for (const [requestId, promise] of this.activeRequests) {
if (this.requestTimeouts.has(requestId)) {
clearTimeout(this.requestTimeouts.get(requestId));
this.requestTimeouts.delete(requestId);
}
}
this.activeRequests.clear();
this.requestTimeouts.clear();
}
async isInstalled() {
try {
const platform = this.getPlatform();
if (platform === 'darwin') {
try {
await fs.access('/Applications/Ollama.app');
return true;
} catch {
const ollamaPath = await this.checkCommand(this.getOllamaCliPath());
return !!ollamaPath;
}
} else {
const ollamaPath = await this.checkCommand(this.getOllamaCliPath());
return !!ollamaPath;
}
} catch (error) {
console.log('[OllamaService] Ollama not found:', error.message);
return false;
}
}
async isServiceRunning() {
try {
const response = await this._makeRequest(`${this.baseUrl}/api/tags`, {
method: 'GET',
timeout: this.requestTimeout
}, 'health');
return response.ok;
} catch (error) {
console.log(`[OllamaService] Service health check failed: ${error.message}`);
return false;
}
}
async startService() {
const platform = this.getPlatform();
try {
if (platform === 'darwin') {
try {
await spawnAsync('open', ['-a', 'Ollama']);
await this.waitForService(() => this.isServiceRunning());
return true;
} catch {
spawn(this.getOllamaCliPath(), ['serve'], {
detached: true,
stdio: 'ignore'
}).unref();
await this.waitForService(() => this.isServiceRunning());
return true;
}
} else {
spawn(this.getOllamaCliPath(), ['serve'], {
detached: true,
stdio: 'ignore',
shell: platform === 'win32'
}).unref();
await this.waitForService(() => this.isServiceRunning());
return true;
}
} catch (error) {
console.error('[OllamaService] Failed to start service:', error);
throw error;
}
}
async stopService() {
return await this.shutdown();
}
async getInstalledModels() {
try {
const response = await this._makeRequest(`${this.baseUrl}/api/tags`, {
method: 'GET',
timeout: this.requestTimeout
}, 'models');
const data = await response.json();
return data.models || [];
} catch (error) {
console.error('[OllamaService] Failed to get installed models:', error.message);
return [];
}
}
async getInstalledModelsList() {
try {
const { stdout } = await spawnAsync(this.getOllamaCliPath(), ['list']);
const lines = stdout.split('\n').filter(line => line.trim());
// Skip header line (NAME, ID, SIZE, MODIFIED)
const modelLines = lines.slice(1);
const models = [];
for (const line of modelLines) {
if (!line.trim()) continue;
// Parse line: "model:tag model_id size modified_time"
const parts = line.split(/\s+/);
if (parts.length >= 3) {
models.push({
name: parts[0],
id: parts[1],
size: parts[2] + (parts[3] === 'GB' || parts[3] === 'MB' ? ' ' + parts[3] : ''),
status: 'installed'
});
}
}
return models;
} catch (error) {
console.log('[OllamaService] Failed to get installed models via CLI, falling back to API');
// Fallback to API if CLI fails
const apiModels = await this.getInstalledModels();
return apiModels.map(model => ({
name: model.name,
id: model.digest || 'unknown',
size: model.size || 'Unknown',
status: 'installed'
}));
}
}
async getModelSuggestions() {
try {
// Get actually installed models
const installedModels = await this.getInstalledModelsList();
// Get user input history from storage (we'll implement this in the frontend)
// For now, just return installed models
return installedModels;
} catch (error) {
console.error('[OllamaService] Failed to get model suggestions:', error);
return [];
}
}
async isModelInstalled(modelName) {
const models = await this.getInstalledModels();
return models.some(model => model.name === modelName);
}
async pullModel(modelName) {
if (!modelName?.trim()) {
throw new Error(`Invalid model name: ${modelName}`);
}
console.log(`[OllamaService] Starting to pull model: ${modelName} via API`);
try {
const response = await fetch(`${this.baseUrl}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
stream: true
})
});
if (!response.ok) {
throw new Error(`Pull API failed: ${response.status} ${response.statusText}`);
}
// Handle Node.js streaming response
return new Promise((resolve, reject) => {
let buffer = '';
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
// Keep incomplete line in buffer
buffer = lines.pop() || '';
// Process complete lines
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const progress = this._parseOllamaPullProgress(data, modelName);
if (progress !== null) {
this.setInstallProgress(modelName, progress);
this.emit('pull-progress', {
model: modelName,
progress,
status: data.status || 'downloading'
});
console.log(`[OllamaService] API Progress: ${progress}% for ${modelName} (${data.status || 'downloading'})`);
}
// Handle completion
if (data.status === 'success') {
console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
this.emit('pull-complete', { model: modelName });
this.clearInstallProgress(modelName);
resolve();
return;
}
} catch (parseError) {
console.warn('[OllamaService] Failed to parse response line:', line);
}
}
});
response.body.on('end', () => {
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const data = JSON.parse(buffer);
if (data.status === 'success') {
console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
this.emit('pull-complete', { model: modelName });
}
} catch (parseError) {
console.warn('[OllamaService] Failed to parse final buffer:', buffer);
}
}
this.clearInstallProgress(modelName);
resolve();
});
response.body.on('error', (error) => {
console.error(`[OllamaService] Stream error for ${modelName}:`, error);
this.clearInstallProgress(modelName);
reject(error);
});
});
} catch (error) {
this.clearInstallProgress(modelName);
console.error(`[OllamaService] Pull model failed:`, error);
throw error;
}
}
_parseOllamaPullProgress(data, modelName) {
// Handle Ollama API response format
if (data.status === 'success') {
return 100;
}
// Handle downloading progress
if (data.total && data.completed !== undefined) {
const progress = Math.round((data.completed / data.total) * 100);
return Math.min(progress, 99); // Don't show 100% until success
}
// Handle status-based progress
const statusProgress = {
'pulling manifest': 5,
'downloading': 10,
'verifying sha256 digest': 90,
'writing manifest': 95,
'removing any unused layers': 98
};
if (data.status && statusProgress[data.status] !== undefined) {
return statusProgress[data.status];
}
return null;
}
async installMacOS(onProgress) {
console.log('[OllamaService] Installing Ollama on macOS using DMG...');
try {
const dmgUrl = 'https://ollama.com/download/Ollama.dmg';
const tempDir = app.getPath('temp');
const dmgPath = path.join(tempDir, 'Ollama.dmg');
const mountPoint = path.join(tempDir, 'OllamaMount');
console.log('[OllamaService] Step 1: Downloading Ollama DMG...');
onProgress?.({ stage: 'downloading', message: 'Downloading Ollama installer...', progress: 0 });
const checksumInfo = DOWNLOAD_CHECKSUMS.ollama.dmg;
await this.downloadWithRetry(dmgUrl, dmgPath, {
expectedChecksum: checksumInfo?.sha256,
onProgress: (progress) => {
onProgress?.({ stage: 'downloading', message: `Downloading... ${progress}%`, progress });
}
});
console.log('[OllamaService] Step 2: Mounting DMG...');
onProgress?.({ stage: 'mounting', message: 'Mounting disk image...', progress: 0 });
await fs.mkdir(mountPoint, { recursive: true });
await spawnAsync('hdiutil', ['attach', dmgPath, '-mountpoint', mountPoint]);
onProgress?.({ stage: 'mounting', message: 'Disk image mounted.', progress: 100 });
console.log('[OllamaService] Step 3: Installing Ollama.app...');
onProgress?.({ stage: 'installing', message: 'Installing Ollama application...', progress: 0 });
await spawnAsync('cp', ['-R', `${mountPoint}/Ollama.app`, '/Applications/']);
onProgress?.({ stage: 'installing', message: 'Application installed.', progress: 100 });
console.log('[OllamaService] Step 4: Setting up CLI path...');
onProgress?.({ stage: 'linking', message: 'Creating command-line shortcut...', progress: 0 });
try {
const script = `do shell script "mkdir -p /usr/local/bin && ln -sf '${this.getOllamaCliPath()}' '/usr/local/bin/ollama'" with administrator privileges`;
await spawnAsync('osascript', ['-e', script]);
onProgress?.({ stage: 'linking', message: 'Shortcut created.', progress: 100 });
} catch (linkError) {
console.error('[OllamaService] CLI symlink creation failed:', linkError.message);
onProgress?.({ stage: 'linking', message: 'Shortcut creation failed (permissions?).', progress: 100 });
// Not throwing an error, as the app might still work
}
console.log('[OllamaService] Step 5: Cleanup...');
onProgress?.({ stage: 'cleanup', message: 'Cleaning up installation files...', progress: 0 });
await spawnAsync('hdiutil', ['detach', mountPoint]);
await fs.unlink(dmgPath).catch(() => {});
await fs.rmdir(mountPoint).catch(() => {});
onProgress?.({ stage: 'cleanup', message: 'Cleanup complete.', progress: 100 });
console.log('[OllamaService] Ollama installed successfully on macOS');
await new Promise(resolve => setTimeout(resolve, 2000));
return true;
} catch (error) {
console.error('[OllamaService] macOS installation failed:', error);
throw new Error(`Failed to install Ollama on macOS: ${error.message}`);
}
}
async installWindows(onProgress) {
console.log('[OllamaService] Installing Ollama on Windows...');
try {
const exeUrl = 'https://ollama.com/download/OllamaSetup.exe';
const tempDir = app.getPath('temp');
const exePath = path.join(tempDir, 'OllamaSetup.exe');
console.log('[OllamaService] Step 1: Downloading Ollama installer...');
onProgress?.({ stage: 'downloading', message: 'Downloading Ollama installer...', progress: 0 });
const checksumInfo = DOWNLOAD_CHECKSUMS.ollama.exe;
await this.downloadWithRetry(exeUrl, exePath, {
expectedChecksum: checksumInfo?.sha256,
onProgress: (progress) => {
onProgress?.({ stage: 'downloading', message: `Downloading... ${progress}%`, progress });
}
});
console.log('[OllamaService] Step 2: Running silent installation...');
onProgress?.({ stage: 'installing', message: 'Installing Ollama...', progress: 0 });
await spawnAsync(exePath, ['/VERYSILENT', '/NORESTART']);
onProgress?.({ stage: 'installing', message: 'Installation complete.', progress: 100 });
console.log('[OllamaService] Step 3: Cleanup...');
onProgress?.({ stage: 'cleanup', message: 'Cleaning up installation files...', progress: 0 });
await fs.unlink(exePath).catch(() => {});
onProgress?.({ stage: 'cleanup', message: 'Cleanup complete.', progress: 100 });
console.log('[OllamaService] Ollama installed successfully on Windows');
await new Promise(resolve => setTimeout(resolve, 3000));
return true;
} catch (error) {
console.error('[OllamaService] Windows installation failed:', error);
throw new Error(`Failed to install Ollama on Windows: ${error.message}`);
}
}
async installLinux() {
console.log('[OllamaService] Installing Ollama on Linux...');
console.log('[OllamaService] Automatic installation on Linux is not supported for security reasons.');
console.log('[OllamaService] Please install Ollama manually:');
console.log('[OllamaService] 1. Visit https://ollama.com/download/linux');
console.log('[OllamaService] 2. Follow the official installation instructions');
console.log('[OllamaService] 3. Or use your package manager if available');
throw new Error('Manual installation required on Linux. Please visit https://ollama.com/download/linux');
}
async warmUpModel(modelName, forceRefresh = false) {
if (!modelName?.trim()) {
console.warn(`[OllamaService] Invalid model name for warm-up`);
return false;
}
// Check if already warmed (and not forcing refresh)
if (!forceRefresh && this.warmedModels.has(modelName)) {
console.log(`[OllamaService] Model ${modelName} already warmed up, skipping`);
return true;
}
// Check if currently warming - return existing Promise
if (this.warmingModels.has(modelName)) {
console.log(`[OllamaService] Model ${modelName} is already warming up, joining existing operation`);
return await this.warmingModels.get(modelName);
}
// Check rate limiting (prevent too frequent attempts)
const lastAttempt = this.lastWarmUpAttempt.get(modelName);
const now = Date.now();
if (lastAttempt && (now - lastAttempt) < 5000) { // 5 second cooldown
console.log(`[OllamaService] Rate limiting warm-up for ${modelName}, try again in ${5 - Math.floor((now - lastAttempt) / 1000)}s`);
return false;
}
// Create and store the warming Promise
const warmingPromise = this._performWarmUp(modelName);
this.warmingModels.set(modelName, warmingPromise);
this.lastWarmUpAttempt.set(modelName, now);
try {
const result = await warmingPromise;
if (result) {
this.warmedModels.add(modelName);
console.log(`[OllamaService] Model ${modelName} successfully warmed up`);
}
return result;
} finally {
// Always clean up the warming Promise
this.warmingModels.delete(modelName);
}
}
async _performWarmUp(modelName) {
console.log(`[OllamaService] Starting warm-up for model: ${modelName}`);
try {
const response = await this._makeRequest(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
messages: [
{ role: 'user', content: 'Hi' }
],
stream: false,
options: {
num_predict: 1, // Minimal response
temperature: 0
}
}),
timeout: this.warmupTimeout
}, `warmup_${modelName}`);
return true;
} catch (error) {
console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
return false;
}
}
async autoWarmUpSelectedModel() {
try {
// Get selected model from ModelStateService
const modelStateService = global.modelStateService;
if (!modelStateService) {
console.log('[OllamaService] ModelStateService not available for auto warm-up');
return false;
}
const selectedModels = modelStateService.getSelectedModels();
const llmModelId = selectedModels.llm;
// Check if it's an Ollama model
const provider = modelStateService.getProviderForModel('llm', llmModelId);
if (provider !== 'ollama') {
console.log('[OllamaService] Selected LLM is not Ollama, skipping warm-up');
return false;
}
// Check if Ollama service is running
const isRunning = await this.isServiceRunning();
if (!isRunning) {
console.log('[OllamaService] Ollama service not running, clearing warm-up cache');
this._clearWarmUpCache();
return false;
}
// Check if model is installed
const isInstalled = await this.isModelInstalled(llmModelId);
if (!isInstalled) {
console.log(`[OllamaService] Model ${llmModelId} not installed, skipping warm-up`);
return false;
}
console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId}`);
return await this.warmUpModel(llmModelId);
} catch (error) {
console.error('[OllamaService] Auto warm-up failed:', error);
return false;
}
}
_clearWarmUpCache() {
this.warmedModels.clear();
this.warmingModels.clear();
this.lastWarmUpAttempt.clear();
console.log('[OllamaService] Warm-up cache cleared');
}
getWarmUpStatus() {
return {
warmedModels: Array.from(this.warmedModels),
warmingModels: Array.from(this.warmingModels.keys()),
lastAttempts: Object.fromEntries(this.lastWarmUpAttempt)
};
}
async shutdown(force = false) {
console.log(`[OllamaService] Shutdown initiated (force: ${force})`);
if (!force && this.warmingModels.size > 0) {
const warmingList = Array.from(this.warmingModels.keys());
console.log(`[OllamaService] Waiting for ${warmingList.length} models to finish warming: ${warmingList.join(', ')}`);
const warmingPromises = Array.from(this.warmingModels.values());
try {
// Use Promise.allSettled instead of race with setTimeout
const results = await Promise.allSettled(warmingPromises);
const completed = results.filter(r => r.status === 'fulfilled').length;
console.log(`[OllamaService] ${completed}/${results.length} warming operations completed`);
} catch (error) {
console.log('[OllamaService] Error waiting for warm-up completion, proceeding with shutdown');
}
}
// Clean up all resources
this._cleanup();
this._clearWarmUpCache();
return super.shutdown(force);
}
async shutdownMacOS(force) {
try {
// Try to quit Ollama.app gracefully
await spawnAsync('osascript', ['-e', 'tell application "Ollama" to quit']);
console.log('[OllamaService] Ollama.app quit successfully');
// Wait a moment for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if still running
const stillRunning = await this.isServiceRunning();
if (stillRunning) {
console.log('[OllamaService] Ollama still running, forcing shutdown');
// Force kill if necessary
await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]);
}
return true;
} catch (error) {
console.log('[OllamaService] Graceful quit failed, trying force kill');
try {
await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]);
return true;
} catch (killError) {
console.error('[OllamaService] Failed to force kill Ollama:', killError);
return false;
}
}
}
async shutdownWindows(force) {
try {
// Try to stop the service gracefully
await spawnAsync('taskkill', ['/IM', 'ollama.exe', '/T']);
console.log('[OllamaService] Ollama process terminated on Windows');
return true;
} catch (error) {
console.log('[OllamaService] Standard termination failed, trying force kill');
try {
await spawnAsync('taskkill', ['/IM', 'ollama.exe', '/F', '/T']);
return true;
} catch (killError) {
console.error('[OllamaService] Failed to force kill Ollama on Windows:', killError);
return false;
}
}
}
async shutdownLinux(force) {
try {
await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]);
console.log('[OllamaService] Ollama process terminated on Linux');
return true;
} catch (error) {
if (force) {
await spawnAsync('pkill', ['-9', '-f', this.getOllamaCliPath()]).catch(() => {});
}
console.error('[OllamaService] Failed to shutdown Ollama on Linux:', error);
return false;
}
}
async getAllModelsWithStatus() {
// Get all installed models directly from Ollama
const installedModels = await this.getInstalledModels();
const models = [];
for (const model of installedModels) {
models.push({
name: model.name,
displayName: model.name, // Use model name as display name
size: model.size || 'Unknown',
description: `Ollama model: ${model.name}`,
installed: true,
installing: this.installationProgress.has(model.name),
progress: this.getInstallProgress(model.name)
});
}
// Also add any models currently being installed
for (const [modelName, progress] of this.installationProgress) {
if (!models.find(m => m.name === modelName)) {
models.push({
name: modelName,
displayName: modelName,
size: 'Unknown',
description: `Ollama model: ${modelName}`,
installed: false,
installing: true,
progress: progress
});
}
}
return models;
}
}
// Export singleton instance
const ollamaService = new OllamaService();
module.exports = ollamaService;

View File

@ -1,279 +0,0 @@
const { screen } = require('electron');
class SmoothMovementManager {
constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
this.windowPool = windowPool;
this.getDisplayById = getDisplayById;
this.getCurrentDisplay = getCurrentDisplay;
this.updateLayout = updateLayout;
this.stepSize = 80;
this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 };
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
this.currentDisplayId = null;
this.animationFrameId = null;
}
/**
* @param {BrowserWindow} win
* @returns {boolean}
*/
_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;
}
}
return false;
}
return true;
}
moveToDisplay(displayId) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const targetDisplay = this.getDisplayById(displayId);
if (!targetDisplay) return;
const currentBounds = header.getBounds();
const currentDisplay = this.getCurrentDisplay(header);
if (currentDisplay.id === targetDisplay.id) return;
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, finalX, finalY);
this.currentDisplayId = targetDisplay.id;
}
hideToEdge(edge, callback, { instant = false } = {}) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
const { x, y } = header.getBounds();
this.lastVisiblePosition = { x, y };
this.hiddenPosition = { edge };
if (instant) {
header.hide();
if (typeof callback === 'function') callback();
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 startTime = Date.now();
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;
return;
}
if (!this._isWindowValid(header)) return;
const { width, height } = windowSize || header.getBounds();
header.setBounds({
x: Math.round(currentX),
y: Math.round(currentY),
width,
height
});
if (progress < 1) {
this.animationFrameId = setTimeout(animate, 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.updateLayout();
}
};
animate();
}
moveToEdge(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const display = this.getCurrentDisplay(header);
const { width, height } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const currentBounds = header.getBounds();
const windowSize = {
width: currentBounds.width,
height: currentBounds.height
};
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;
}
header.setBounds({
x: Math.round(targetX),
y: Math.round(targetY),
width: windowSize.width,
height: windowSize.height
});
this.headerPosition = { x: targetX, y: targetY };
this.updateLayout();
}
destroy() {
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.isAnimating = false;
console.log('[Movement] Manager destroyed');
}
}
module.exports = SmoothMovementManager;

View File

@ -1,217 +0,0 @@
const { screen } = require('electron');
/**
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다.
* @param {BrowserWindow} window - 확인할 객체
* @returns {Display} Electron의 Display 객체
*/
function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
const windowBounds = window.getBounds();
const windowCenter = {
x: windowBounds.x + windowBounds.width / 2,
y: windowBounds.y + windowBounds.height / 2,
};
return screen.getDisplayNearestPoint(windowCenter);
}
class WindowLayoutManager {
/**
* @param {Map<string, BrowserWindow>} windowPool - 관리할 창들의
*/
constructor(windowPool) {
this.windowPool = windowPool;
this.isUpdating = false;
this.PADDING = 80;
}
/**
* 모든 창의 레이아웃 업데이트를 요청합니다.
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
*/
updateLayout() {
if (this.isUpdating) return;
this.isUpdating = true;
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
/**
* 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
*/
positionWindows() {
const header = this.windowPool.get('header');
if (!header?.getBounds) return;
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}} 레이아웃 전략
*/
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);
if (spaceBelow >= 400) {
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' };
} else if (spaceAbove >= 400) {
return { name: 'above', primary: 'above', secondary: relativeX < 0.5 ? 'right' : 'left' };
} else if (relativeX < 0.3 && spaceRight >= 800) {
return { name: 'right-side', primary: 'right', secondary: spaceBelow > spaceAbove ? 'below' : 'above' };
} else if (relativeX > 0.7 && spaceLeft >= 800) {
return { name: 'left-side', primary: 'left', secondary: spaceBelow > spaceAbove ? 'below' : 'above' };
} else {
return { name: 'adaptive', primary: spaceBelow > spaceAbove ? 'below' : 'above', secondary: spaceRight > spaceLeft ? 'right' : 'left' };
}
}
/**
* 'ask' 'listen' 창의 위치를 조정합니다.
*/
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
if (!askVisible && !listenVisible) return;
const PAD = 8;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
let askBounds = askVisible ? ask.getBounds() : null;
let listenBounds = listenVisible ? listen.getBounds() : null;
if (askVisible && listenVisible) {
const combinedWidth = listenBounds.width + PAD + askBounds.width;
let groupStartXRel = headerCenterXRel - combinedWidth / 2;
let listenXRel = groupStartXRel;
let askXRel = groupStartXRel + listenBounds.width + PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenBounds.width + PAD;
}
if (askXRel + askBounds.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askBounds.width;
listenXRel = askXRel - listenBounds.width - PAD;
}
let yRel = (strategy.primary === 'above')
? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD;
listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yRel + workAreaY), width: listenBounds.width, height: listenBounds.height });
ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yRel + workAreaY), width: askBounds.width, height: askBounds.height });
} else {
const win = askVisible ? ask : listen;
const winBounds = askVisible ? askBounds : listenBounds;
let xRel = headerCenterXRel - winBounds.width / 2;
let yRel = (strategy.primary === 'above')
? headerBounds.y - workAreaY - winBounds.height - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD;
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), width: winBounds.width, height: winBounds.height });
}
}
/**
* '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} 겹침 여부
*/
boundsOverlap(bounds1, bounds2) {
const margin = 10;
return !(
bounds1.x + bounds1.width + margin < bounds2.x ||
bounds2.x + bounds2.width + margin < bounds1.x ||
bounds1.y + bounds1.height + margin < bounds2.y ||
bounds2.y + bounds2.height + margin < bounds1.y
);
}
}
module.exports = WindowLayoutManager;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,54 @@
const DOWNLOAD_CHECKSUMS = {
ollama: {
dmg: {
url: 'https://ollama.com/download/Ollama.dmg',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
exe: {
url: 'https://ollama.com/download/OllamaSetup.exe',
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/ggml-org/whisper.cpp/resolve/main/ggml-tiny.bin',
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
},
'whisper-base': {
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-base.bin',
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
},
'whisper-small': {
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-small.bin',
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
},
'whisper-medium': {
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/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/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
}
}
}
}
};
module.exports = { DOWNLOAD_CHECKSUMS };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,68 @@
const sqliteRepository = require('./sqlite.repository');
function getBaseRepository() {
// For now, we only have sqlite. This could be expanded later.
return sqliteRepository;
}
const providerSettingsRepositoryAdapter = {
// Core CRUD operations
async getByProvider(provider) {
const repo = getBaseRepository();
return await repo.getByProvider(provider);
},
async getAll() {
const repo = getBaseRepository();
return await repo.getAll();
},
async upsert(provider, settings) {
const repo = getBaseRepository();
const now = Date.now();
const settingsWithMeta = {
...settings,
provider,
updated_at: now,
created_at: settings.created_at || now
};
return await repo.upsert(provider, settingsWithMeta);
},
async remove(provider) {
const repo = getBaseRepository();
return await repo.remove(provider);
},
async removeAll() {
const repo = getBaseRepository();
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
};

View File

@ -0,0 +1,160 @@
const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(provider) {
const db = sqliteClient.getDb();
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 getAll() {
const db = sqliteClient.getDb();
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(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 (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(
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
);
return { changes: result.changes };
}
function remove(provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?');
const result = stmt.run(provider);
return { changes: result.changes };
}
function removeAll() {
const db = sqliteClient.getDb();
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,
getAll,
upsert,
remove,
removeAll,
getRawApiKeys,
getActiveProvider,
setActiveProvider,
getActiveSettings
};

View File

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

View File

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

View File

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

View File

@ -9,6 +9,8 @@ try {
keytar = null;
}
const permissionService = require('./permissionService');
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
let sessionKey = null; // In-memory fallback key
@ -31,6 +33,8 @@ async function initializeKey(userId) {
throw new Error('A user ID must be provided to initialize the encryption key.');
}
let keyRetrieved = false;
if (keytar) {
try {
let key = await keytar.getPassword(SERVICE_NAME, userId);
@ -41,6 +45,7 @@ async function initializeKey(userId) {
console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
} else {
console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
keyRetrieved = true;
}
sessionKey = key;
} catch (error) {
@ -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,
};

View File

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

View File

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

View File

@ -0,0 +1,437 @@
const { EventEmitter } = require('events');
const Store = require('electron-store');
const { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings');
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' });
}
async initialize() {
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.');
}
async _initializeEncryption() {
try {
const rows = await providerSettingsRepository.getRawApiKeys();
if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) {
console.log('[ModelStateService] Encrypted keys detected, initializing encryption...');
const userIdForMigration = this.authService.getCurrentUserId();
await encryptionService.initializeKey(userIdForMigration);
} else {
console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.');
}
} catch (err) {
console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
}
}
async _runMigrations() {
console.log('[ModelStateService] Checking for data migrations...');
const userId = this.authService.getCurrentUserId();
try {
const sqliteClient = require('./sqliteClient');
const db = sqliteClient.getDb();
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_model_selections'").get();
if (tableExists) {
const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId);
if (selections) {
console.log('[ModelStateService] Migrating from user_model_selections table...');
if (selections.llm_model) {
const llmProvider = this.getProviderForModel(selections.llm_model, 'llm');
if (llmProvider) {
await this.setSelectedModel('llm', selections.llm_model);
}
}
if (selections.stt_model) {
const sttProvider = this.getProviderForModel(selections.stt_model, 'stt');
if (sttProvider) {
await this.setSelectedModel('stt', selections.stt_model);
}
}
db.prepare('DROP TABLE user_model_selections').run();
console.log('[ModelStateService] user_model_selections migration complete.');
}
}
} catch (error) {
console.error('[ModelStateService] user_model_selections migration failed:', error);
}
try {
const legacyData = this.store.get(`users.${userId}`);
if (legacyData && legacyData.apiKeys) {
console.log('[ModelStateService] Migrating from electron-store...');
for (const [provider, apiKey] of Object.entries(legacyData.apiKeys)) {
if (apiKey && PROVIDERS[provider]) {
await this.setApiKey(provider, apiKey);
}
}
if (legacyData.selectedModels?.llm) {
await this.setSelectedModel('llm', legacyData.selectedModels.llm);
}
if (legacyData.selectedModels?.stt) {
await this.setSelectedModel('stt', legacyData.selectedModels.stt);
}
this.store.delete(`users.${userId}`);
console.log('[ModelStateService] electron-store migration complete.');
}
} catch (error) {
console.error('[ModelStateService] electron-store migration failed:', error);
}
}
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'];
for (const type of types) {
const currentModelId = selectedModels[type];
let isCurrentModelValid = false;
const forceReselection = forceReselectionForTypes.includes(type);
if (currentModelId && !forceReselection) {
const provider = this.getProviderForModel(currentModelId, type);
const apiKey = apiKeys[provider];
if (provider && apiKey) {
isCurrentModelValid = true;
}
}
if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`);
const availableModels = await this.getAvailableModels(type);
if (availableModels.length > 0) {
const apiModel = availableModels.find(model => {
const provider = this.getProviderForModel(model.id, type);
return provider && provider !== 'ollama' && provider !== 'whisper';
});
const newModel = apiModel || availableModels[0];
await this.setSelectedModel(type, newModel.id);
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`);
} else {
await providerSettingsRepository.setActiveProvider(null, type);
if (!isInitialBoot) {
this.emit('state-updated', await this.getLiveState());
}
}
}
}
}
async setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key.`);
// 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다.
const previousSettings = await providerSettingsRepository.getByProvider('openai-glass');
const wasPreviouslyConfigured = !!previousSettings?.api_key;
// 항상 새로운 가상 키로 업데이트합니다.
await this.setApiKey('openai-glass', virtualKey);
if (virtualKey) {
// 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다.
if (!wasPreviouslyConfigured) {
console.log('[ModelStateService] First-time setup for openai-glass, setting default models.');
const llmModel = PROVIDERS['openai-glass']?.llmModels[0];
const sttModel = PROVIDERS['openai-glass']?.sttModels[0];
if (llmModel) await this.setSelectedModel('llm', llmModel.id);
if (sttModel) await this.setSelectedModel('stt', sttModel.id);
} else {
console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.');
}
} else {
// 로그아웃 시, 현재 활성화된 모델이 openai-glass인 경우에만 다른 모델로 전환합니다.
const selected = await this.getSelectedModels();
const llmProvider = this.getProviderForModel(selected.llm, 'llm');
const sttProvider = this.getProviderForModel(selected.stt, 'stt');
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);
}
}
}
async setApiKey(provider, key) {
console.log(`[ModelStateService] setApiKey for ${provider}`);
if (!provider) {
throw new Error('Provider is required');
}
// 'openai-glass'는 자체 인증 키를 사용하므로 유효성 검사를 건너뜁니다.
if (provider !== 'openai-glass') {
const validationResult = await this.validateApiKey(provider, key);
if (!validationResult.success) {
console.warn(`[ModelStateService] API key validation failed for ${provider}: ${validationResult.error}`);
return validationResult;
}
}
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
await providerSettingsRepository.upsert(provider, { ...existingSettings, api_key: finalKey });
// 키가 추가/변경되었으므로, 해당 provider의 모델을 자동 선택할 수 있는지 확인
await this._autoSelectAvailableModels([]);
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return { success: true };
}
async getAllApiKeys() {
const allSettings = await providerSettingsRepository.getAll();
const apiKeys = {};
allSettings.forEach(s => {
if (s.provider !== 'openai-glass') {
apiKeys[s.provider] = s.api_key;
}
});
return apiKeys;
}
async removeApiKey(provider) {
const setting = await providerSettingsRepository.getByProvider(provider);
if (setting && setting.api_key) {
await providerSettingsRepository.upsert(provider, { ...setting, api_key: null });
await this._autoSelectAvailableModels(['llm', 'stt']);
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return true;
}
return false;
}
/**
* 사용자가 Firebase에 로그인했는지 확인합니다.
*/
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
}
/**
* 유효한 API 키가 하나라도 설정되어 있는지 확인합니다.
*/
async hasValidApiKey() {
if (this.isLoggedInWithFirebase()) return true;
const allSettings = await providerSettingsRepository.getAll();
return allSettings.some(s => s.api_key && s.api_key.trim().length > 0);
}
getProviderForModel(arg1, arg2) {
// Compatibility: support both (type, modelId) old order and (modelId, type) new order
let type, modelId;
if (arg1 === 'llm' || arg1 === 'stt') {
type = arg1;
modelId = arg2;
} else {
modelId = arg1;
type = arg2;
}
if (!modelId || !type) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models && models.some(m => m.id === modelId)) {
return providerId;
}
}
if (type === 'llm') {
const installedModels = ollamaModelRepository.getInstalledModels();
if (installedModels.some(m => m.name === modelId)) return 'ollama';
}
return null;
}
async getSelectedModels() {
const active = await providerSettingsRepository.getActiveSettings();
return {
llm: active.llm?.selected_llm_model || null,
stt: active.stt?.selected_stt_model || null,
};
}
async setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(modelId, type);
if (!provider) {
console.warn(`[ModelStateService] No provider found for model ${modelId}`);
return false;
}
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
const newSettings = { ...existingSettings };
if (type === 'llm') {
newSettings.selected_llm_model = modelId;
} else {
newSettings.selected_stt_model = modelId;
}
await providerSettingsRepository.upsert(provider, newSettings);
await providerSettingsRepository.setActiveProvider(provider, type);
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (provider: ${provider})`);
if (type === 'llm' && provider === 'ollama') {
require('./localAIManager').warmUpModel(modelId).catch(err => console.warn(err));
}
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return true;
}
async getAvailableModels(type) {
const allSettings = await providerSettingsRepository.getAll();
const available = [];
const modelListKey = type === 'llm' ? 'llmModels' : 'sttModels';
for (const setting of allSettings) {
if (!setting.api_key) continue;
const providerId = setting.provider;
if (providerId === 'ollama' && type === 'llm') {
const installed = ollamaModelRepository.getInstalledModels();
available.push(...installed.map(m => ({ id: m.name, name: m.name })));
} else if (PROVIDERS[providerId]?.[modelListKey]) {
available.push(...PROVIDERS[providerId][modelListKey]);
}
}
return [...new Map(available.map(item => [item.id, item])).values()];
}
async getCurrentModelInfo(type) {
const activeSetting = await providerSettingsRepository.getActiveProvider(type);
if (!activeSetting) return null;
const model = type === 'llm' ? activeSetting.selected_llm_model : activeSetting.selected_stt_model;
if (!model) return null;
return {
provider: activeSetting.provider,
model: model,
apiKey: activeSetting.api_key,
};
}
// --- 핸들러 및 유틸리티 메서드 ---
async validateApiKey(provider, key) {
if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
return { success: false, error: 'API key cannot be empty.' };
}
const ProviderClass = getProviderClass(provider);
if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
return { success: true };
}
try {
return await ProviderClass.validateApiKey(key);
} catch (error) {
return { success: false, error: 'An unexpected error occurred during validation.' };
}
}
getProviderConfig() {
const config = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
config[key] = rest;
}
return config;
}
async handleRemoveApiKey(provider) {
const success = await this.removeApiKey(provider);
if (success) {
const selectedModels = await this.getSelectedModels();
if (!selectedModels.llm && !selectedModels.stt) {
this.emit('force-show-apikey-header');
}
}
return success;
}
/*-------------- Compatibility Helpers --------------*/
async handleValidateKey(provider, key) {
return await this.setApiKey(provider, key);
}
async handleSetSelectedModel(type, modelId) {
return await this.setSelectedModel(type, modelId);
}
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;
}
}
const modelStateService = new ModelStateService();
module.exports = modelStateService;

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,12 @@
const { ipcMain, BrowserWindow } = require('electron');
const Store = require('electron-store');
const authService = require('../../common/services/authService');
const authService = require('../common/services/authService');
const settingsRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../electron/windowManager');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager');
// New imports for common services
const modelStateService = require('../common/services/modelStateService');
const localAIManager = require('../common/services/localAIManager');
const store = new Store({
name: 'pickle-glass-settings',
@ -19,6 +23,52 @@ const NOTIFICATION_CONFIG = {
RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
};
// New facade functions for model state management
async function getModelSettings() {
try {
const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([
modelStateService.getProviderConfig(),
modelStateService.getAllApiKeys(),
modelStateService.getSelectedModels(),
modelStateService.getAvailableModels('llm'),
modelStateService.getAvailableModels('stt')
]);
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
} catch (error) {
console.error('[SettingsService] Error getting model settings:', error);
return { success: false, error: error.message };
}
}
async function clearApiKey(provider) {
const success = await modelStateService.handleRemoveApiKey(provider);
return { success };
}
async function setSelectedModel(type, modelId) {
const success = await modelStateService.handleSetSelectedModel(type, modelId);
return { success };
}
// LocalAI facade functions
async function getOllamaStatus() {
return localAIManager.getServiceStatus('ollama');
}
async function ensureOllamaReady() {
const status = await localAIManager.getServiceStatus('ollama');
if (!status.installed || !status.running) {
await localAIManager.startService('ollama');
}
return { success: true };
}
async function shutdownOllama() {
return localAIManager.stopService('ollama');
}
// window targeting system
class WindowNotificationManager {
constructor() {
@ -324,6 +374,7 @@ async function removeApiKey() {
}
});
console.log('[SettingsService] API key removed for all providers');
return { success: true };
} catch (error) {
console.error('[SettingsService] Error removing API key:', error);
@ -373,57 +424,6 @@ function initialize() {
// cleanup
windowNotificationManager.cleanup();
// IPC handlers for settings
ipcMain.handle('settings:getSettings', async () => {
return await getSettings();
});
ipcMain.handle('settings:saveSettings', async (event, settings) => {
return await saveSettings(settings);
});
// IPC handlers for presets
ipcMain.handle('settings:getPresets', async () => {
return await getPresets();
});
ipcMain.handle('settings:getPresetTemplates', async () => {
return await getPresetTemplates();
});
ipcMain.handle('settings:createPreset', async (event, title, prompt) => {
return await createPreset(title, prompt);
});
ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => {
return await updatePreset(id, title, prompt);
});
ipcMain.handle('settings:deletePreset', async (event, id) => {
return await deletePreset(id);
});
ipcMain.handle('settings:saveApiKey', async (event, apiKey, provider) => {
return await saveApiKey(apiKey, provider);
});
ipcMain.handle('settings:removeApiKey', async () => {
return await removeApiKey();
});
ipcMain.handle('settings:updateContentProtection', async (event, enabled) => {
return await updateContentProtection(enabled);
});
ipcMain.handle('settings:get-auto-update', async () => {
return await getAutoUpdateSetting();
});
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => {
console.log('[SettingsService] Setting auto update setting:', isEnabled);
return await setAutoUpdateSetting(isEnabled);
});
console.log('[SettingsService] Initialized and ready.');
}
@ -455,4 +455,13 @@ module.exports = {
removeApiKey,
updateContentProtection,
getAutoUpdateSetting,
setAutoUpdateSetting,
// Model settings facade
getModelSettings,
clearApiKey,
setSelectedModel,
// Ollama facade
getOllamaStatus,
ensureOllamaReady,
shutdownOllama
};

View File

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

View File

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

View File

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

View File

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

View File

@ -1,2 +1,306 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
// src/preload.js
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// Platform information for renderer processes
platform: {
isLinux: process.platform === 'linux',
isMacOS: process.platform === 'darwin',
isWindows: process.platform === 'win32',
platform: process.platform
},
// Common utilities used across multiple components
common: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
// User state listener (used by multiple components)
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
},
// UI Component specific namespaces
// src/ui/app/ApiKeyHeader.js
apiKeyHeader: {
// Model & Provider Management
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
// LocalAI 통합 API
getLocalAIStatus: (service) => ipcRenderer.invoke('localai:get-status', service),
installLocalAI: (service, options) => ipcRenderer.invoke('localai:install', { service, options }),
startLocalAIService: (service) => ipcRenderer.invoke('localai:start-service', service),
stopLocalAIService: (service) => ipcRenderer.invoke('localai:stop-service', service),
installLocalAIModel: (service, modelId, options) => ipcRenderer.invoke('localai:install-model', { service, modelId, options }),
getInstalledModels: (service) => ipcRenderer.invoke('localai:get-installed-models', service),
// Legacy support (호환성 위해 유지)
getOllamaStatus: () => ipcRenderer.invoke('localai:get-status', 'ollama'),
getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
installOllama: () => ipcRenderer.invoke('localai:install', { service: 'ollama' }),
startOllamaService: () => ipcRenderer.invoke('localai:start-service', 'ollama'),
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'),
// Window Management
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
// Listeners
// LocalAI 통합 이벤트 리스너
onLocalAIProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
removeOnLocalAIProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
onLocalAIComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
removeOnLocalAIComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback),
onLocalAIError: (callback) => ipcRenderer.on('localai:error-notification', callback),
removeOnLocalAIError: (callback) => ipcRenderer.removeListener('localai:error-notification', callback),
onLocalAIModelReady: (callback) => ipcRenderer.on('localai:model-ready', callback),
removeOnLocalAIModelReady: (callback) => ipcRenderer.removeListener('localai:model-ready', callback),
// Remove all listeners (for cleanup)
removeAllListeners: () => {
// LocalAI 통합 이벤트
ipcRenderer.removeAllListeners('localai:install-progress');
ipcRenderer.removeAllListeners('localai:installation-complete');
ipcRenderer.removeAllListeners('localai:error-notification');
ipcRenderer.removeAllListeners('localai:model-ready');
ipcRenderer.removeAllListeners('localai:service-status-changed');
}
},
// src/ui/app/HeaderController.js
headerController: {
// State Management
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
reInitializeModelState: () => ipcRenderer.invoke('model:re-initialize-state'),
// Window Management
resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
// Permissions
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
checkPermissionsCompleted: () => ipcRenderer.invoke('check-permissions-completed'),
// Listeners
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback),
removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback),
onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback),
removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback),
},
// src/ui/app/MainHeader.js
mainHeader: {
// Window Management
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
sendHeaderAnimationFinished: (state) => ipcRenderer.send('header-animation-finished', state),
// Settings Window Management
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// Generic invoke (for dynamic channel names)
// invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
sendListenButtonClick: (listenButtonText) => ipcRenderer.invoke('listen:changeSession', listenButtonText),
sendAskButtonClick: () => ipcRenderer.invoke('ask:toggleAskButton'),
sendToggleAllWindowsVisibility: () => ipcRenderer.invoke('shortcut:toggleAllWindowsVisibility'),
// Listeners
onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback),
removeOnListenChangeSessionResult: (callback) => ipcRenderer.removeListener('listen:changeSessionResult', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback)
},
// src/ui/app/PermissionHeader.js
permissionHeader: {
// Permission Management
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
markKeychainCompleted: () => ipcRenderer.invoke('mark-keychain-completed'),
checkKeychainCompleted: (uid) => ipcRenderer.invoke('check-keychain-completed', uid),
initializeEncryptionKey: () => ipcRenderer.invoke('initialize-encryption-key') // New for keychain
},
// src/ui/app/PickleGlassApp.js
pickleGlassApp: {
// Listeners
onClickThroughToggled: (callback) => ipcRenderer.on('click-through-toggled', callback),
removeOnClickThroughToggled: (callback) => ipcRenderer.removeListener('click-through-toggled', callback),
removeAllClickThroughListeners: () => ipcRenderer.removeAllListeners('click-through-toggled')
},
// src/ui/ask/AskView.js
askView: {
// Window Management
closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
// Message Handling
sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
// Listeners
onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback),
onAskStreamError: (callback) => ipcRenderer.on('ask-response-stream-error', callback),
removeOnAskStreamError: (callback) => ipcRenderer.removeListener('ask-response-stream-error', callback),
// Listeners
onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback),
onScrollResponseUp: (callback) => ipcRenderer.on('aks:scrollResponseUp', callback),
removeOnScrollResponseUp: (callback) => ipcRenderer.removeListener('aks:scrollResponseUp', callback),
onScrollResponseDown: (callback) => ipcRenderer.on('aks:scrollResponseDown', callback),
removeOnScrollResponseDown: (callback) => ipcRenderer.removeListener('aks:scrollResponseDown', callback)
},
// src/ui/listen/ListenView.js
listenView: {
// Window Management
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
// Listeners
onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
removeOnSessionStateChanged: (callback) => ipcRenderer.removeListener('session-state-changed', callback)
},
// src/ui/listen/stt/SttView.js
sttView: {
// Listeners
onSttUpdate: (callback) => ipcRenderer.on('stt-update', callback),
removeOnSttUpdate: (callback) => ipcRenderer.removeListener('stt-update', callback)
},
// src/ui/listen/summary/SummaryView.js
summaryView: {
// Message Handling
sendQuestionFromSummary: (text) => ipcRenderer.invoke('ask:sendQuestionFromSummary', text),
// Listeners
onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback),
removeOnSummaryUpdate: (callback) => ipcRenderer.removeListener('summary-update', callback),
removeAllSummaryUpdateListeners: () => ipcRenderer.removeAllListeners('summary-update')
},
// src/ui/settings/SettingsView.js
settingsView: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
openPersonalizePage: () => ipcRenderer.invoke('open-personalize-page'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
// Model & Provider Management
getModelSettings: () => ipcRenderer.invoke('settings:get-model-settings'), // Facade call
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
getAllKeys: () => ipcRenderer.invoke('model:get-all-keys'),
getAvailableModels: (type) => ipcRenderer.invoke('model:get-available-models', type),
getSelectedModels: () => ipcRenderer.invoke('model:get-selected-models'),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
saveApiKey: (key) => ipcRenderer.invoke('model:save-api-key', key),
removeApiKey: (provider) => ipcRenderer.invoke('model:remove-api-key', provider),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
// Ollama Management
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
shutdownOllama: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
// Whisper Management
getWhisperInstalledModels: () => ipcRenderer.invoke('whisper:get-installed-models'),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
// Settings Management
getPresets: () => ipcRenderer.invoke('settings:getPresets'),
getAutoUpdate: () => ipcRenderer.invoke('settings:get-auto-update'),
setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
getCurrentShortcuts: () => ipcRenderer.invoke('settings:getCurrentShortcuts'),
openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'),
// Window Management
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
// Progress Tracking
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
// Listeners
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback),
removeOnSettingsUpdated: (callback) => ipcRenderer.removeListener('settings-updated', callback),
onPresetsUpdated: (callback) => ipcRenderer.on('presets-updated', callback),
removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
// 통합 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('shortcut:saveShortcuts', shortcuts),
getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'),
closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'),
// Listeners
onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback),
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback)
},
// src/ui/app/content.html inline scripts
content: {
// Listeners
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),
},
// src/ui/listen/audioCore/listenCapture.js
listenCapture: {
// Audio Management
sendMicAudioContent: (data) => ipcRenderer.invoke('listen:sendMicAudio', data),
sendSystemAudioContent: (data) => ipcRenderer.invoke('listen:sendSystemAudio', data),
startMacosSystemAudio: () => ipcRenderer.invoke('listen:startMacosSystemAudio'),
stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
// Session Management
isSessionActive: () => ipcRenderer.invoke('listen:isSessionActive'),
// Listeners
onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
removeOnSystemAudioData: (callback) => ipcRenderer.removeListener('system-audio-data', callback)
},
// src/ui/listen/audioCore/renderer.js
renderer: {
// Listeners
onChangeListenCaptureState: (callback) => ipcRenderer.on('change-listen-capture-state', callback),
removeOnChangeListenCaptureState: (callback) => ipcRenderer.removeListener('change-listen-capture-state', callback)
}
});

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement {
.container {
-webkit-app-region: drag;
width: 285px;
height: 220px;
/* height is now set dynamically */
padding: 18px 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 16px;
@ -103,6 +103,12 @@ export class PermissionHeader extends LitElement {
margin-top: auto;
}
.form-content.all-granted {
flex-grow: 1;
justify-content: center;
margin-top: 0;
}
.subtitle {
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
@ -258,24 +264,60 @@ export class PermissionHeader extends LitElement {
static properties = {
microphoneGranted: { type: String },
screenGranted: { type: String },
keychainGranted: { type: String },
isChecking: { type: String },
continueCallback: { type: Function }
continueCallback: { type: Function },
userMode: { type: String }, // 'local' or 'firebase'
};
constructor() {
super();
this.microphoneGranted = 'unknown';
this.screenGranted = 'unknown';
this.keychainGranted = 'unknown';
this.isChecking = false;
this.continueCallback = null;
this.userMode = 'local'; // Default to local
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('userMode')) {
const newHeight = this.userMode === 'firebase' ? 280 : 220;
console.log(`[PermissionHeader] User mode changed to ${this.userMode}, requesting resize to ${newHeight}px`);
this.dispatchEvent(new CustomEvent('request-resize', {
detail: { height: newHeight },
bubbles: true,
composed: true
}));
}
}
async connectedCallback() {
super.connectedCallback();
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
this.userMode = userState.mode;
} catch (e) {
console.error('[PermissionHeader] Failed to get user state', e);
this.userMode = 'local'; // Fallback to local
}
}
await this.checkPermissions();
// Set up periodic permission check
this.permissionCheckInterval = setInterval(() => {
this.permissionCheckInterval = setInterval(async () => {
if (window.api) {
try {
const userState = await window.api.common.getCurrentUser();
this.userMode = userState.mode;
} catch (e) {
this.userMode = 'local';
}
}
this.checkPermissions();
}, 1000);
}
@ -288,30 +330,35 @@ export class PermissionHeader extends LitElement {
}
async checkPermissions() {
if (!window.require || this.isChecking) return;
if (!window.api || this.isChecking) return;
this.isChecking = true;
const { ipcRenderer } = window.require('electron');
try {
const permissions = await ipcRenderer.invoke('check-system-permissions');
const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Permission check result:', permissions);
const prevMic = this.microphoneGranted;
const prevScreen = this.screenGranted;
const prevKeychain = this.keychainGranted;
this.microphoneGranted = permissions.microphone;
this.screenGranted = permissions.screen;
this.keychainGranted = permissions.keychain;
// if permissions changed == UI update
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted || prevKeychain !== this.keychainGranted) {
console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate();
}
const isKeychainRequired = this.userMode === 'firebase';
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
// if all permissions granted == automatically continue
if (this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' &&
keychainOk &&
this.continueCallback) {
console.log('[PermissionHeader] All permissions granted, proceeding automatically');
setTimeout(() => this.handleContinue(), 500);
@ -324,13 +371,12 @@ export class PermissionHeader extends LitElement {
}
async handleMicrophoneClick() {
if (!window.require || this.microphoneGranted === 'granted') return;
if (!window.api || this.microphoneGranted === 'granted') return;
console.log('[PermissionHeader] Requesting microphone permission...');
const { ipcRenderer } = window.require('electron');
try {
const result = await ipcRenderer.invoke('check-system-permissions');
const result = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Microphone permission result:', result);
if (result.microphone === 'granted') {
@ -340,7 +386,7 @@ export class PermissionHeader extends LitElement {
}
if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
const res = await ipcRenderer.invoke('request-microphone-permission');
const res = await window.api.permissionHeader.requestMicrophonePermission();
if (res.status === 'granted' || res.success === true) {
this.microphoneGranted = 'granted';
this.requestUpdate();
@ -357,13 +403,12 @@ export class PermissionHeader extends LitElement {
}
async handleScreenClick() {
if (!window.require || this.screenGranted === 'granted') return;
if (!window.api || this.screenGranted === 'granted') return;
console.log('[PermissionHeader] Checking screen recording permission...');
const { ipcRenderer } = window.require('electron');
try {
const permissions = await ipcRenderer.invoke('check-system-permissions');
const permissions = await window.api.permissionHeader.checkSystemPermissions();
console.log('[PermissionHeader] Screen permission check result:', permissions);
if (permissions.screen === 'granted') {
@ -373,7 +418,7 @@ export class PermissionHeader extends LitElement {
}
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
console.log('[PermissionHeader] Opening screen recording preferences...');
await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
await window.api.permissionHeader.openSystemPreferences('screen-recording');
}
// Check permissions again after a delay
@ -384,18 +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.require) {
const { ipcRenderer } = window.require('electron');
if (window.api && isKeychainRequired) {
try {
await ipcRenderer.invoke('mark-permissions-completed');
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);
}
}
@ -405,16 +471,19 @@ export class PermissionHeader extends LitElement {
handleClose() {
console.log('Close button clicked');
if (window.require) {
window.require('electron').ipcRenderer.invoke('quit-application');
if (window.api) {
window.api.common.quitApplication();
}
}
render() {
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
const isKeychainRequired = this.userMode === 'firebase';
const containerHeight = isKeychainRequired ? 280 : 220;
const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted';
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && keychainOk;
return html`
<div class="container">
<div class="container" style="height: ${containerHeight}px">
<button class="close-button" @click=${this.handleClose} title="Close application">
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
@ -422,8 +491,9 @@ 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' : ''}">
@ -453,34 +523,60 @@ export class PermissionHeader extends LitElement {
<span>Screen Recording</span>
`}
</div>
${isKeychainRequired ? html`
<div class="permission-item ${this.keychainGranted === 'granted' ? 'granted' : ''}">
${this.keychainGranted === 'granted' ? html`
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
</svg>
<span>Data Encryption </span>
` : html`
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M18 8a6 6 0 01-7.744 5.668l-1.649 1.652c-.63.63-1.706.19-1.706-.742V12.18a.75.75 0 00-1.5 0v2.696c0 .932-1.075 1.372-1.706.742l-1.649-1.652A6 6 0 112 8zm-4 0a.75.75 0 00.75-.75A3.75 3.75 0 018.25 4a.75.75 0 000 1.5 2.25 2.25 0 012.25 2.25.75.75 0 00.75.75z" clip-rule="evenodd" />
</svg>
<span>Data Encryption</span>
`}
</div>
` : ''}
</div>
${this.microphoneGranted !== 'granted' ? html`
<button
class="action-button"
@click=${this.handleMicrophoneClick}
?disabled=${this.microphoneGranted === 'granted'}
>
Grant Microphone Access
${this.microphoneGranted === 'granted' ? 'Microphone Access Granted' : 'Grant Microphone Access'}
</button>
` : ''}
${this.screenGranted !== 'granted' ? html`
<button
class="action-button"
@click=${this.handleScreenClick}
?disabled=${this.screenGranted === 'granted'}
>
Grant Screen Recording Access
${this.screenGranted === 'granted' ? 'Screen Recording Granted' : 'Grant Screen Recording Access'}
</button>
` : ''}
${allGranted ? html`
${isKeychainRequired ? html`
<button
class="action-button"
@click=${this.handleKeychainClick}
?disabled=${this.keychainGranted === 'granted'}
>
${this.keychainGranted === 'granted' ? 'Encryption Enabled' : 'Enable Encryption'}
</button>
<div class="subtitle" style="visibility: ${this.keychainGranted === 'granted' ? 'hidden' : 'visible'}">
Stores the key to encrypt your data. Press "<b>Always Allow</b>" to continue.
</div>
` : ''}
` : html`
<button
class="continue-button"
@click=${this.handleContinue}
>
Continue to Pickle Glass
</button>
` : ''}
`}
</div>
</div>
`;

View File

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

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

@ -0,0 +1,236 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class WelcomeHeader extends LitElement {
static styles = css`
:host {
display: block;
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
.container {
width: 100%;
box-sizing: border-box;
height: auto;
padding: 24px 16px;
background: rgba(0, 0, 0, 0.64);
box-shadow: 0px 0px 0px 1.5px rgba(255, 255, 255, 0.64) inset;
border-radius: 16px;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 32px;
display: inline-flex;
-webkit-app-region: drag;
}
.close-button {
-webkit-app-region: no-drag;
position: absolute;
top: 16px;
right: 16px;
width: 20px;
height: 20px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 5px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
z-index: 10;
font-size: 16px;
line-height: 1;
padding: 0;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
.header-section {
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 4px;
display: flex;
}
.title {
color: white;
font-size: 18px;
font-weight: 700;
}
.subtitle {
color: white;
font-size: 14px;
font-weight: 500;
}
.option-card {
width: 100%;
justify-content: flex-start;
align-items: flex-start;
gap: 8px;
display: inline-flex;
}
.divider {
width: 1px;
align-self: stretch;
position: relative;
background: #bebebe;
border-radius: 2px;
}
.option-content {
flex: 1 1 0;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
gap: 8px;
display: inline-flex;
min-width: 0;
}
.option-title {
color: white;
font-size: 14px;
font-weight: 700;
}
.option-description {
color: #dcdcdc;
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: 0.12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.action-button {
-webkit-app-region: no-drag;
padding: 8px 10px;
background: rgba(132.6, 132.6, 132.6, 0.8);
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.16);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.5);
justify-content: center;
align-items: center;
gap: 6px;
display: flex;
cursor: pointer;
transition: background-color 0.2s;
}
.action-button:hover {
background: rgba(150, 150, 150, 0.9);
}
.button-text {
color: white;
font-size: 12px;
font-weight: 600;
}
.button-icon {
width: 12px;
height: 12px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.arrow-icon {
border: solid white;
border-width: 0 1.2px 1.2px 0;
display: inline-block;
padding: 3px;
transform: rotate(-45deg);
-webkit-transform: rotate(-45deg);
}
.footer {
align-self: stretch;
text-align: center;
color: #dcdcdc;
font-size: 12px;
font-weight: 500;
line-height: 19.2px;
}
.footer-link {
text-decoration: underline;
cursor: pointer;
-webkit-app-region: no-drag;
}
`;
static properties = {
loginCallback: { type: Function },
apiKeyCallback: { type: Function },
};
constructor() {
super();
this.loginCallback = () => {};
this.apiKeyCallback = () => {};
this.handleClose = this.handleClose.bind(this);
}
updated(changedProperties) {
super.updated(changedProperties);
this.dispatchEvent(new CustomEvent('content-changed', { bubbles: true, composed: true }));
}
handleClose() {
if (window.api?.common) {
window.api.common.quitApplication();
}
}
render() {
return html`
<div class="container">
<button class="close-button" @click=${this.handleClose}>×</button>
<div class="header-section">
<div class="title">Welcome to Glass</div>
<div class="subtitle">Choose how to connect your AI model</div>
</div>
<div class="option-card">
<div class="divider"></div>
<div class="option-content">
<div class="option-title">Quick start with default API key</div>
<div class="option-description">
100% free with Pickle's OpenAI key<br/>No personal data collected<br/>Sign up with Google in seconds
</div>
</div>
<button class="action-button" @click=${this.loginCallback}>
<div class="button-text">Open Browser to Log in</div>
<div class="button-icon"><div class="arrow-icon"></div></div>
</button>
</div>
<div class="option-card">
<div class="divider"></div>
<div class="option-content">
<div class="option-title">Use Personal API keys</div>
<div class="option-description">
Costs may apply based on your API usage<br/>No personal data collected<br/>Use your own API keys (OpenAI, Gemini, etc.)
</div>
</div>
<button class="action-button" @click=${this.apiKeyCallback}>
<div class="button-text">Enter Your API Key</div>
<div class="button-icon"><div class="arrow-icon"></div></div>
</button>
</div>
<div class="footer">
Glass does not collect your personal data
<span class="footer-link" @click=${this.openPrivacyPolicy}>See details</span>
</div>
</div>
`;
}
openPrivacyPolicy() {
console.log('🔊 openPrivacyPolicy WelcomeHeader');
if (window.api?.common) {
window.api.common.openExternal('https://pickle.com/privacy-policy');
}
}
}
customElements.define('welcome-header', WelcomeHeader);

123
src/ui/app/content.html Normal file
View File

@ -0,0 +1,123 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval'" />
<title>Pickle Glass Content</title>
<style>
:root {
--background-transparent: transparent;
--text-color: #e5e5e7;
--border-color: rgba(255, 255, 255, 0.2);
--header-background: rgba(0, 0, 0, 0.8);
--header-actions-color: rgba(255, 255, 255, 0.6);
--main-content-background: rgba(0, 0, 0, 0.8);
--button-background: rgba(0, 0, 0, 0.5);
--button-border: rgba(255, 255, 255, 0.1);
--icon-button-color: rgb(229, 229, 231);
--hover-background: rgba(255, 255, 255, 0.1);
--input-background: rgba(0, 0, 0, 0.3);
--placeholder-color: rgba(255, 255, 255, 0.4);
--focus-border-color: #007aff;
--focus-box-shadow: rgba(0, 122, 255, 0.2);
--input-focus-background: rgba(0, 0, 0, 0.5);
--scrollbar-track: rgba(0, 0, 0, 0.2);
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
--preview-video-background: rgba(0, 0, 0, 0.9);
--preview-video-border: rgba(255, 255, 255, 0.15);
--option-label-color: rgba(255, 255, 255, 0.8);
--screen-option-background: rgba(0, 0, 0, 0.4);
--screen-option-hover-background: rgba(0, 0, 0, 0.6);
--screen-option-selected-background: rgba(0, 122, 255, 0.15);
--screen-option-text: rgba(255, 255, 255, 0.7);
--description-color: rgba(255, 255, 255, 0.6);
--start-button-background: white;
--start-button-color: black;
--start-button-border: white;
--start-button-hover-background: rgba(255, 255, 255, 0.8);
--start-button-hover-border: rgba(0, 0, 0, 0.2);
--text-input-button-background: #007aff;
--text-input-button-hover: #0056b3;
--link-color: #007aff;
--key-background: rgba(255, 255, 255, 0.1);
--scrollbar-background: rgba(0, 0, 0, 0.4);
/* Layout-specific variables */
--header-padding: 10px 20px;
--header-font-size: 16px;
--header-gap: 12px;
--header-button-padding: 8px 16px;
--header-icon-padding: 8px;
--header-font-size-small: 13px;
--main-content-padding: 20px;
--main-content-margin-top: 10px;
--icon-size: 24px;
--border-radius: 7px;
--content-border-radius: 7px;
}
/* Compact layout styles */
:root.compact-layout {
--header-padding: 6px 12px;
--header-font-size: 13px;
--header-gap: 6px;
--header-button-padding: 4px 8px;
--header-icon-padding: 4px;
--header-font-size-small: 10px;
--main-content-padding: 10px;
--main-content-margin-top: 2px;
--icon-size: 16px;
--border-radius: 4px;
--content-border-radius: 4px;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
overflow: hidden;
background: transparent;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
pickle-glass-app {
display: block;
width: 100%;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
transform-origin: center center;
contain: layout style paint;
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
}
</style>
</head>
<body>
<script src="../assets/marked-4.3.0.min.js"></script>
<script type="module" src="../../../public/build/content.js"></script>
<pickle-glass-app id="pickle-glass"></pickle-glass-app>
<script>
window.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('pickle-glass');
});
</script>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body>
</html>

View File

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

View File

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

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