Merge branch 'refactor/localmodel' into feature/encryption

This commit is contained in:
samtiz 2025-07-15 16:29:16 +09:00
commit a27ab05fa8
33 changed files with 2988 additions and 1490 deletions

78
package-lock.json generated
View File

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

View File

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

View File

@ -1,5 +1,5 @@
// src/bridge/featureBridge.js // src/bridge/featureBridge.js
const { ipcMain, app } = require('electron'); const { ipcMain, app, BrowserWindow } = require('electron');
const settingsService = require('../features/settings/settingsService'); const settingsService = require('../features/settings/settingsService');
const authService = require('../features/common/services/authService'); const authService = require('../features/common/services/authService');
const whisperService = require('../features/common/services/whisperService'); const whisperService = require('../features/common/services/whisperService');
@ -7,7 +7,7 @@ const ollamaService = require('../features/common/services/ollamaService');
const modelStateService = require('../features/common/services/modelStateService'); const modelStateService = require('../features/common/services/modelStateService');
const shortcutsService = require('../features/shortcuts/shortcutsService'); const shortcutsService = require('../features/shortcuts/shortcutsService');
const presetRepository = require('../features/common/repositories/preset'); const presetRepository = require('../features/common/repositories/preset');
const localAIManager = require('../features/common/services/localAIManager');
const askService = require('../features/ask/askService'); const askService = require('../features/ask/askService');
const listenService = require('../features/listen/listenService'); const listenService = require('../features/listen/listenService');
const permissionService = require('../features/common/services/permissionService'); const permissionService = require('../features/common/services/permissionService');
@ -29,9 +29,12 @@ module.exports = {
ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama()); ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama());
// Shortcuts // Shortcuts
ipcMain.handle('get-current-shortcuts', async () => await shortcutsService.loadKeybinds()); ipcMain.handle('settings:getCurrentShortcuts', async () => await shortcutsService.loadKeybinds());
ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults()); ipcMain.handle('shortcut:getDefaultShortcuts', async () => await shortcutsService.handleRestoreDefaults());
ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds)); 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 // Permissions
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
@ -116,6 +119,115 @@ module.exports = {
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
ipcMain.handle('model:re-initialize-state', () => modelStateService.initialize()); ipcMain.handle('model:re-initialize-state', () => 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.'); console.log('[FeatureBridge] Initialized with all feature handlers.');
}, },

View File

@ -1,20 +1,23 @@
// src/bridge/windowBridge.js // src/bridge/windowBridge.js
const { ipcMain, shell } = require('electron'); const { ipcMain, shell } = require('electron');
const windowManager = require('../window/windowManager');
// Bridge는 단순히 IPC 핸들러를 등록하는 역할만 함 (비즈니스 로직 없음)
module.exports = { module.exports = {
initialize() { initialize() {
// initialize 시점에 windowManager를 require하여 circular dependency 문제 해결
const windowManager = require('../window/windowManager');
// 기존 IPC 핸들러들
ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection()); ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection());
ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args)); ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args));
ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus()); ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus());
ipcMain.handle('open-shortcut-editor', () => windowManager.openShortcutEditor()); ipcMain.on('show-settings-window', () => windowManager.showSettingsWindow());
ipcMain.on('show-settings-window', (event, bounds) => windowManager.showSettingsWindow(bounds));
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow()); ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow()); ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
ipcMain.handle('open-login-page', () => windowManager.openLoginPage()); ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage()); ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction)); ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));
ipcMain.handle('open-external', (event, url) => shell.openExternal(url)); ipcMain.handle('open-external', (event, url) => shell.openExternal(url));
// Newly moved handlers from windowManager // Newly moved handlers from windowManager
@ -24,9 +27,6 @@ module.exports = {
ipcMain.handle('move-header', (event, newX, newY) => windowManager.moveHeader(newX, newY)); ipcMain.handle('move-header', (event, newX, newY) => windowManager.moveHeader(newX, newY));
ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY)); ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight)); ipcMain.handle('adjust-window-height', (event, targetHeight) => windowManager.adjustWindowHeight(event.sender, targetHeight));
ipcMain.handle('toggle-all-windows-visibility', () => windowManager.toggleAllWindowsVisibility());
// ipcMain.on('animation-finished', (event) => windowManager.handleAnimationFinished(event.sender));
// ipcMain.handle('ask:closeAskWindow', () => windowManager.closeAskWindow());
}, },
notifyFocusChange(win, isFocused) { notifyFocusChange(win, isFocused) {

View File

@ -3,7 +3,6 @@ const { createStreamingLLM } = require('../common/ai/factory');
// Lazy require helper to avoid circular dependency issues // Lazy require helper to avoid circular dependency issues
const getWindowManager = () => require('../../window/windowManager'); const getWindowManager = () => require('../../window/windowManager');
const internalBridge = require('../../bridge/internalBridge'); const internalBridge = require('../../bridge/internalBridge');
const { EVENTS } = internalBridge;
const getWindowPool = () => { const getWindowPool = () => {
try { try {
@ -162,11 +161,11 @@ class AskService {
this._broadcastState(); this._broadcastState();
} else { } else {
if (askWindow && askWindow.isVisible()) { if (askWindow && askWindow.isVisible()) {
internalBridge.emit('request-window-visibility', { name: 'ask', visible: false }); internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
this.state.isVisible = false; this.state.isVisible = false;
} else { } else {
console.log('[AskService] Showing hidden Ask window'); console.log('[AskService] Showing hidden Ask window');
internalBridge.emit('request-window-visibility', { name: 'ask', visible: true }); internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
this.state.isVisible = true; this.state.isVisible = true;
} }
if (this.state.isVisible) { if (this.state.isVisible) {
@ -192,7 +191,7 @@ class AskService {
}; };
this._broadcastState(); this._broadcastState();
internalBridge.emit('request-window-visibility', { name: 'ask', visible: false }); internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
return { success: true }; return { success: true };
} }
@ -217,7 +216,16 @@ class AskService {
* @returns {Promise<{success: boolean, response?: string, error?: string}>} * @returns {Promise<{success: boolean, response?: string, error?: string}>}
*/ */
async sendMessage(userPrompt, conversationHistoryRaw=[]) { async sendMessage(userPrompt, conversationHistoryRaw=[]) {
// ensureAskWindowVisible(); internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
this.state = {
...this.state,
isLoading: true,
isStreaming: false,
currentQuestion: userPrompt,
currentResponse: '',
showTextInput: false,
};
this._broadcastState();
if (this.abortController) { if (this.abortController) {
this.abortController.abort('New request received.'); this.abortController.abort('New request received.');
@ -226,27 +234,11 @@ class AskService {
const { signal } = this.abortController; const { signal } = this.abortController;
// if (!userPrompt || userPrompt.trim().length === 0) {
// console.warn('[AskService] Cannot process empty message');
// return { success: false, error: 'Empty message' };
// }
let sessionId; let sessionId;
try { try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
this.state = {
...this.state,
isLoading: true,
isStreaming: false,
currentQuestion: userPrompt,
currentResponse: '',
showTextInput: false,
};
this._broadcastState();
sessionId = await sessionRepository.getOrCreateActive('ask'); sessionId = await sessionRepository.getOrCreateActive('ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);

View File

@ -57,6 +57,14 @@ const PROVIDERS = {
], ],
sttModels: [], sttModels: [],
}, },
'deepgram': {
name: 'Deepgram',
handler: () => require("./providers/deepgram"),
llmModels: [],
sttModels: [
{ id: 'nova-3', name: 'Nova-3 (General)' },
],
},
'ollama': { 'ollama': {
name: 'Ollama (Local)', name: 'Ollama (Local)',
handler: () => require("./providers/ollama"), handler: () => require("./providers/ollama"),
@ -148,6 +156,7 @@ function getProviderClass(providerId) {
'openai': 'OpenAIProvider', 'openai': 'OpenAIProvider',
'anthropic': 'AnthropicProvider', 'anthropic': 'AnthropicProvider',
'gemini': 'GeminiProvider', 'gemini': 'GeminiProvider',
'deepgram': 'DeepgramProvider',
'ollama': 'OllamaProvider', 'ollama': 'OllamaProvider',
'whisper': 'WhisperProvider' 'whisper': 'WhisperProvider'
}; };

View File

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

View File

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

View File

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

View File

@ -96,21 +96,13 @@ const LATEST_SCHEMA = {
{ name: 'api_key', type: 'TEXT' }, { name: 'api_key', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' }, { name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_model', type: 'TEXT' }, { name: 'selected_stt_model', type: 'TEXT' },
{ name: 'is_active_llm', type: 'INTEGER DEFAULT 0' },
{ name: 'is_active_stt', type: 'INTEGER DEFAULT 0' },
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'updated_at', type: 'INTEGER' } { name: 'updated_at', type: 'INTEGER' }
], ],
constraints: ['PRIMARY KEY (uid, provider)'] constraints: ['PRIMARY KEY (uid, provider)']
}, },
user_model_selections: {
columns: [
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
{ name: 'selected_llm_provider', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_provider', type: 'TEXT' },
{ name: 'selected_stt_model', type: 'TEXT' },
{ name: 'updated_at', type: 'INTEGER' }
]
},
shortcuts: { shortcuts: {
columns: [ columns: [
{ name: 'action', type: 'TEXT PRIMARY KEY' }, { name: 'action', type: 'TEXT PRIMARY KEY' },

View File

@ -57,6 +57,24 @@ const providerSettingsRepositoryAdapter = {
// as it's part of the local-first boot sequence. // as it's part of the local-first boot sequence.
const uid = authService.getCurrentUserId(); const uid = authService.getCurrentUserId();
return await sqliteRepository.getRawApiKeysByUid(uid); return await sqliteRepository.getRawApiKeysByUid(uid);
},
async getActiveProvider(type) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.getActiveProvider(uid, type);
},
async setActiveProvider(provider, type) {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.setActiveProvider(uid, provider, type);
},
async getActiveSettings() {
const repo = getBaseRepository();
const uid = authService.getCurrentUserId();
return await repo.getActiveSettings(uid);
} }
}; };

View File

@ -1,5 +1,4 @@
const sqliteClient = require('../../services/sqliteClient'); const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(uid, provider) { function getByProvider(uid, provider) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
@ -20,23 +19,30 @@ function getAllByUid(uid) {
return results.map(result => { return results.map(result => {
if (result.api_key) { if (result.api_key) {
result.api_key = encryptionService.decrypt(result.api_key); result.api_key = result.api_key;
} }
return result; return result;
}); });
} }
function upsert(uid, provider, settings) { function upsert(uid, provider, settings) {
// Validate: prevent direct setting of active status
if (settings.is_active_llm || settings.is_active_stt) {
console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.');
}
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE) // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(` const stmt = db.prepare(`
INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at) INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(uid, provider) DO UPDATE SET ON CONFLICT(uid, provider) DO UPDATE SET
api_key = excluded.api_key, api_key = excluded.api_key,
selected_llm_model = excluded.selected_llm_model, selected_llm_model = excluded.selected_llm_model,
selected_stt_model = excluded.selected_stt_model, selected_stt_model = excluded.selected_stt_model,
-- is_active_llm and is_active_stt are NOT updated here
-- Use setActiveProvider() to change active status
updated_at = excluded.updated_at updated_at = excluded.updated_at
`); `);
@ -46,6 +52,8 @@ function upsert(uid, provider, settings) {
settings.api_key || null, settings.api_key || null,
settings.selected_llm_model || null, settings.selected_llm_model || null,
settings.selected_stt_model || null, settings.selected_stt_model || null,
0, // is_active_llm - always 0, use setActiveProvider to activate
0, // is_active_stt - always 0, use setActiveProvider to activate
settings.created_at || Date.now(), settings.created_at || Date.now(),
settings.updated_at settings.updated_at
); );
@ -73,11 +81,80 @@ function getRawApiKeysByUid(uid) {
return stmt.all(uid); return stmt.all(uid);
} }
// Get active provider for a specific type (llm or stt)
function getActiveProvider(uid, type) {
const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
const stmt = db.prepare(`SELECT * FROM provider_settings WHERE uid = ? AND ${column} = 1`);
const result = stmt.get(uid) || null;
if (result && result.api_key) {
result.api_key = result.api_key;
}
return result;
}
// Set active provider for a specific type
function setActiveProvider(uid, 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 WHERE uid = ?`);
deactivateStmt.run(uid);
// Then activate the specified provider
if (provider) {
const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE uid = ? AND provider = ?`);
activateStmt.run(uid, provider);
}
})();
return { success: true };
}
// Get all active settings (both llm and stt)
function getActiveSettings(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare(`
SELECT * FROM provider_settings
WHERE uid = ? AND (is_active_llm = 1 OR is_active_stt = 1)
ORDER BY provider
`);
const results = stmt.all(uid);
// Decrypt API keys and organize by type
const activeSettings = {
llm: null,
stt: null
};
results.forEach(result => {
if (result.api_key) {
result.api_key = result.api_key;
}
if (result.is_active_llm) {
activeSettings.llm = result;
}
if (result.is_active_stt) {
activeSettings.stt = result;
}
});
return activeSettings;
}
module.exports = { module.exports = {
getByProvider, getByProvider,
getAllByUid, getAllByUid,
upsert, upsert,
remove, remove,
removeAllByUid, removeAllByUid,
getRawApiKeysByUid getRawApiKeysByUid,
getActiveProvider,
setActiveProvider,
getActiveSettings
}; };

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

@ -6,7 +6,6 @@ const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService'); const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session'); const sessionRepository = require('../repositories/session');
const providerSettingsRepository = require('../repositories/providerSettings'); const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections');
const permissionService = require('./permissionService'); const permissionService = require('./permissionService');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
@ -48,7 +47,6 @@ class AuthService {
sessionRepository.setAuthService(this); sessionRepository.setAuthService(this);
providerSettingsRepository.setAuthService(this); providerSettingsRepository.setAuthService(this);
userModelSelectionsRepository.setAuthService(this);
} }
initialize() { initialize() {

View File

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

View File

@ -1,308 +0,0 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const { EventEmitter } = require('events');
const { BrowserWindow } = require('electron');
const path = require('path');
const os = require('os');
const https = require('https');
const fs = require('fs');
const crypto = require('crypto');
const execAsync = promisify(exec);
class LocalAIServiceBase extends EventEmitter {
constructor(serviceName) {
super();
this.serviceName = serviceName;
this.baseUrl = null;
this.installationProgress = new Map();
}
// 모든 윈도우에 이벤트 브로드캐스트
_broadcastToAllWindows(eventName, data = null) {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
if (data !== null) {
win.webContents.send(eventName, data);
} else {
win.webContents.send(eventName);
}
}
});
}
getPlatform() {
return process.platform;
}
async checkCommand(command) {
try {
const platform = this.getPlatform();
const checkCmd = platform === 'win32' ? 'where' : 'which';
const { stdout } = await execAsync(`${checkCmd} ${command}`);
return stdout.trim();
} catch (error) {
return null;
}
}
async isInstalled() {
throw new Error('isInstalled() must be implemented by subclass');
}
async isServiceRunning() {
throw new Error('isServiceRunning() must be implemented by subclass');
}
async startService() {
throw new Error('startService() must be implemented by subclass');
}
async stopService() {
throw new Error('stopService() must be implemented by subclass');
}
async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) {
for (let i = 0; i < maxAttempts; i++) {
if (await checkFn()) {
console.log(`[${this.serviceName}] Service is ready`);
return true;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
throw new Error(`${this.serviceName} service failed to start within timeout`);
}
getInstallProgress(modelName) {
return this.installationProgress.get(modelName) || 0;
}
setInstallProgress(modelName, progress) {
this.installationProgress.set(modelName, progress);
// 각 서비스에서 직접 브로드캐스트하도록 변경
}
clearInstallProgress(modelName) {
this.installationProgress.delete(modelName);
}
async autoInstall(onProgress) {
const platform = this.getPlatform();
console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`);
try {
switch(platform) {
case 'darwin':
return await this.installMacOS(onProgress);
case 'win32':
return await this.installWindows(onProgress);
case 'linux':
return await this.installLinux();
default:
throw new Error(`Unsupported platform: ${platform}`);
}
} catch (error) {
console.error(`[${this.serviceName}] Auto-installation failed:`, error);
throw error;
}
}
async installMacOS() {
throw new Error('installMacOS() must be implemented by subclass');
}
async installWindows() {
throw new Error('installWindows() must be implemented by subclass');
}
async installLinux() {
throw new Error('installLinux() must be implemented by subclass');
}
// parseProgress method removed - using proper REST API now
async shutdown(force = false) {
console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`);
const isRunning = await this.isServiceRunning();
if (!isRunning) {
console.log(`[${this.serviceName}] Service not running, nothing to shutdown`);
return true;
}
const platform = this.getPlatform();
try {
switch(platform) {
case 'darwin':
return await this.shutdownMacOS(force);
case 'win32':
return await this.shutdownWindows(force);
case 'linux':
return await this.shutdownLinux(force);
default:
console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`);
return false;
}
} catch (error) {
console.error(`[${this.serviceName}] Error during shutdown:`, error);
return false;
}
}
async shutdownMacOS(force) {
throw new Error('shutdownMacOS() must be implemented by subclass');
}
async shutdownWindows(force) {
throw new Error('shutdownWindows() must be implemented by subclass');
}
async shutdownLinux(force) {
throw new Error('shutdownLinux() must be implemented by subclass');
}
async downloadFile(url, destination, options = {}) {
const {
onProgress = null,
headers = { 'User-Agent': 'Glass-App' },
timeout = 300000, // 5 minutes default
modelId = null // 모델 ID를 위한 추가 옵션
} = options;
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
let downloadedSize = 0;
let totalSize = 0;
const request = https.get(url, { headers }, (response) => {
// Handle redirects (301, 302, 307, 308)
if ([301, 302, 307, 308].includes(response.statusCode)) {
file.close();
fs.unlink(destination, () => {});
if (!response.headers.location) {
reject(new Error('Redirect without location header'));
return;
}
console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`);
this.downloadFile(response.headers.location, destination, options)
.then(resolve)
.catch(reject);
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlink(destination, () => {});
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`));
return;
}
totalSize = parseInt(response.headers['content-length'], 10) || 0;
response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100);
// 이벤트 기반 진행률 보고는 각 서비스에서 직접 처리
// 기존 콜백 지원 (호환성 유지)
if (onProgress) {
onProgress(progress, downloadedSize, totalSize);
}
}
});
response.pipe(file);
file.on('finish', () => {
file.close(() => {
// download-complete 이벤트는 각 서비스에서 직접 처리
resolve({ success: true, size: downloadedSize });
});
});
});
request.on('timeout', () => {
request.destroy();
file.close();
fs.unlink(destination, () => {});
reject(new Error('Download timeout'));
});
request.on('error', (err) => {
file.close();
fs.unlink(destination, () => {});
this.emit('download-error', { url, error: err, modelId });
reject(err);
});
request.setTimeout(timeout);
file.on('error', (err) => {
fs.unlink(destination, () => {});
reject(err);
});
});
}
async downloadWithRetry(url, destination, options = {}) {
const {
maxRetries = 3,
retryDelay = 1000,
expectedChecksum = null,
modelId = null, // 모델 ID를 위한 추가 옵션
...downloadOptions
} = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.downloadFile(url, destination, {
...downloadOptions,
modelId
});
if (expectedChecksum) {
const isValid = await this.verifyChecksum(destination, expectedChecksum);
if (!isValid) {
fs.unlinkSync(destination);
throw new Error('Checksum verification failed');
}
console.log(`[${this.serviceName}] Checksum verified successfully`);
}
return result;
} catch (error) {
if (attempt === maxRetries) {
// download-error 이벤트는 각 서비스에서 직접 처리
throw error;
}
console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
}
}
}
async verifyChecksum(filePath, expectedChecksum) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => {
const fileChecksum = hash.digest('hex');
console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`);
console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`);
resolve(fileChecksum === expectedChecksum);
});
stream.on('error', reject);
});
}
}
module.exports = LocalAIServiceBase;

View File

@ -1,138 +0,0 @@
export class LocalProgressTracker {
constructor(serviceName) {
this.serviceName = serviceName;
this.activeOperations = new Map(); // operationId -> { controller, onProgress }
// Check if we're in renderer process with window.api available
if (!window.api) {
throw new Error(`${serviceName} requires Electron environment with contextBridge`);
}
this.globalProgressHandler = (event, data) => {
const operation = this.activeOperations.get(data.model || data.modelId);
if (operation && !operation.controller.signal.aborted) {
operation.onProgress(data.progress);
}
};
// Set up progress listeners based on service name
if (serviceName.toLowerCase() === 'ollama') {
window.api.settingsView.onOllamaPullProgress(this.globalProgressHandler);
} else if (serviceName.toLowerCase() === 'whisper') {
window.api.settingsView.onWhisperDownloadProgress(this.globalProgressHandler);
}
this.progressEvent = serviceName.toLowerCase();
}
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 {
let result;
// Use appropriate API call based on service and operation
if (this.serviceName.toLowerCase() === 'ollama' && operationType === 'install') {
result = await window.api.settingsView.pullOllamaModel(operationId);
} else if (this.serviceName.toLowerCase() === 'whisper' && operationType === 'download') {
result = await window.api.settingsView.downloadWhisperModel(operationId);
} else {
throw new Error(`Unsupported operation: ${this.serviceName}:${operationType}`);
}
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();
// Remove progress listeners based on service name
if (this.progressEvent === 'ollama') {
window.api.settingsView.removeOnOllamaPullProgress(this.globalProgressHandler);
} else if (this.progressEvent === 'whisper') {
window.api.settingsView.removeOnWhisperDownloadProgress(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,11 +1,9 @@
const Store = require('electron-store'); const Store = require('electron-store');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const { BrowserWindow } = require('electron');
const { PROVIDERS, getProviderClass } = require('../ai/factory'); const { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService'); const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings'); const providerSettingsRepository = require('../repositories/providerSettings');
const userModelSelectionsRepository = require('../repositories/userModelSelections');
const authService = require('./authService'); const authService = require('./authService');
class ModelStateService extends EventEmitter { class ModelStateService extends EventEmitter {
@ -29,25 +27,54 @@ class ModelStateService extends EventEmitter {
this.hasMigrated = false; this.hasMigrated = false;
} }
// 모든 윈도우에 이벤트 브로드캐스트
_broadcastToAllWindows(eventName, data = null) {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
if (data !== null) {
win.webContents.send(eventName, data);
} else {
win.webContents.send(eventName);
}
}
});
}
async initialize() { async initialize() {
console.log('[ModelStateService] Initializing...'); console.log('[ModelStateService] Initializing...');
await this._loadStateForCurrentUser(); await this._loadStateForCurrentUser();
// LocalAI 상태 변경 이벤트 구독
this.setupLocalAIStateSync();
console.log('[ModelStateService] Initialization complete'); console.log('[ModelStateService] Initialization complete');
} }
setupLocalAIStateSync() {
// LocalAI 서비스 상태 변경 감지
// LocalAIManager에서 직접 이벤트를 받아 처리
const localAIManager = require('./localAIManager');
localAIManager.on('state-changed', (service, status) => {
this.handleLocalAIStateChange(service, status);
});
}
handleLocalAIStateChange(service, state) {
console.log(`[ModelStateService] LocalAI state changed: ${service}`, state);
// Ollama의 경우 로드된 모델 정보도 처리
if (service === 'ollama' && state.loadedModels) {
console.log(`[ModelStateService] Ollama loaded models: ${state.loadedModels.join(', ')}`);
// 선택된 모델이 메모리에서 언로드되었는지 확인
const selectedLLM = this.state.selectedModels.llm;
if (selectedLLM && this.getProviderForModel('llm', selectedLLM) === 'ollama') {
if (!state.loadedModels.includes(selectedLLM)) {
console.log(`[ModelStateService] Selected model ${selectedLLM} is not loaded in memory`);
// 필요시 자동 워밍업 트리거
this._triggerAutoWarmUp();
}
}
}
// 자동 선택 재실행 (필요시)
if (!state.installed || !state.running) {
const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : [];
this._autoSelectAvailableModels(types);
}
// UI 업데이트 알림
this.emit('state-updated', this.state);
}
_logCurrentSelection() { _logCurrentSelection() {
const llmModel = this.state.selectedModels.llm; const llmModel = this.state.selectedModels.llm;
const sttModel = this.state.selectedModels.stt; const sttModel = this.state.selectedModels.stt;
@ -96,6 +123,66 @@ class ModelStateService extends EventEmitter {
}); });
} }
async _migrateUserModelSelections() {
console.log('[ModelStateService] Checking for user_model_selections migration...');
const userId = this.authService.getCurrentUserId();
try {
// Check if user_model_selections table exists
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) {
console.log('[ModelStateService] user_model_selections table does not exist, skipping migration');
return;
}
// Get existing user_model_selections data
const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId);
if (!selections) {
console.log('[ModelStateService] No user_model_selections data to migrate');
return;
}
console.log('[ModelStateService] Found user_model_selections data, migrating to provider_settings...');
// Migrate LLM selection
if (selections.llm_model) {
const llmProvider = this.getProviderForModel('llm', selections.llm_model);
if (llmProvider) {
await providerSettingsRepository.upsert(llmProvider, {
selected_llm_model: selections.llm_model
});
await providerSettingsRepository.setActiveProvider(llmProvider, 'llm');
console.log(`[ModelStateService] Migrated LLM: ${selections.llm_model} (provider: ${llmProvider})`);
}
}
// Migrate STT selection
if (selections.stt_model) {
const sttProvider = this.getProviderForModel('stt', selections.stt_model);
if (sttProvider) {
await providerSettingsRepository.upsert(sttProvider, {
selected_stt_model: selections.stt_model
});
await providerSettingsRepository.setActiveProvider(sttProvider, 'stt');
console.log(`[ModelStateService] Migrated STT: ${selections.stt_model} (provider: ${sttProvider})`);
}
}
// Delete the migrated data from user_model_selections
db.prepare('DELETE FROM user_model_selections WHERE uid = ?').run(userId);
console.log('[ModelStateService] user_model_selections migration completed');
} catch (error) {
console.error('[ModelStateService] user_model_selections migration failed:', error);
// Don't throw - continue with normal operation
}
}
async _migrateFromElectronStore() { async _migrateFromElectronStore() {
console.log('[ModelStateService] Starting migration from electron-store to database...'); console.log('[ModelStateService] Starting migration from electron-store to database...');
const userId = this.authService.getCurrentUserId(); const userId = this.authService.getCurrentUserId();
@ -125,17 +212,26 @@ class ModelStateService extends EventEmitter {
} }
// Migrate global model selections // Migrate global model selections
if (selectedModels.llm || selectedModels.stt) { if (selectedModels.llm) {
const llmProvider = selectedModels.llm ? this.getProviderForModel('llm', selectedModels.llm) : null; const llmProvider = this.getProviderForModel('llm', selectedModels.llm);
const sttProvider = selectedModels.stt ? this.getProviderForModel('stt', selectedModels.stt) : null; if (llmProvider) {
await providerSettingsRepository.upsert(llmProvider, {
selected_llm_model: selectedModels.llm
});
await providerSettingsRepository.setActiveProvider(llmProvider, 'llm');
console.log(`[ModelStateService] Migrated LLM model selection: ${selectedModels.llm}`);
}
}
await userModelSelectionsRepository.upsert({ if (selectedModels.stt) {
selected_llm_provider: llmProvider, const sttProvider = this.getProviderForModel('stt', selectedModels.stt);
selected_llm_model: selectedModels.llm, if (sttProvider) {
selected_stt_provider: sttProvider, await providerSettingsRepository.upsert(sttProvider, {
selected_stt_model: selectedModels.stt selected_stt_model: selectedModels.stt
}); });
console.log('[ModelStateService] Migrated global model selections'); await providerSettingsRepository.setActiveProvider(sttProvider, 'stt');
console.log(`[ModelStateService] Migrated STT model selection: ${selectedModels.stt}`);
}
} }
// Mark migration as complete by removing legacy data // Mark migration as complete by removing legacy data
@ -169,11 +265,11 @@ class ModelStateService extends EventEmitter {
} }
} }
// Load global model selections // Load active model selections from provider settings
const modelSelections = await userModelSelectionsRepository.get(); const activeSettings = await providerSettingsRepository.getActiveSettings();
const selectedModels = { const selectedModels = {
llm: modelSelections?.selected_llm_model || null, llm: activeSettings.llm?.selected_llm_model || null,
stt: modelSelections?.selected_stt_model || null stt: activeSettings.stt?.selected_stt_model || null
}; };
this.state = { this.state = {
@ -217,6 +313,9 @@ class ModelStateService extends EventEmitter {
console.warn('[ModelStateService] Error while checking encrypted keys:', err.message); console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
} }
// Check for user_model_selections migration first
await this._migrateUserModelSelections();
// Try to load from database first // Try to load from database first
await this._loadStateFromDatabase(); await this._loadStateFromDatabase();
@ -252,17 +351,38 @@ class ModelStateService extends EventEmitter {
} }
} }
// Save global model selections // Save model selections and update active providers
const llmProvider = this.state.selectedModels.llm ? this.getProviderForModel('llm', this.state.selectedModels.llm) : null; const llmModel = this.state.selectedModels.llm;
const sttProvider = this.state.selectedModels.stt ? this.getProviderForModel('stt', this.state.selectedModels.stt) : null; const sttModel = this.state.selectedModels.stt;
if (llmProvider || sttProvider || this.state.selectedModels.llm || this.state.selectedModels.stt) { if (llmModel) {
await userModelSelectionsRepository.upsert({ const llmProvider = this.getProviderForModel('llm', llmModel);
selected_llm_provider: llmProvider, if (llmProvider) {
selected_llm_model: this.state.selectedModels.llm, // Update the provider's selected model
selected_stt_provider: sttProvider, await providerSettingsRepository.upsert(llmProvider, {
selected_stt_model: this.state.selectedModels.stt selected_llm_model: llmModel
}); });
// Set as active LLM provider
await providerSettingsRepository.setActiveProvider(llmProvider, 'llm');
}
} else {
// Deactivate all LLM providers if no model selected
await providerSettingsRepository.setActiveProvider(null, 'llm');
}
if (sttModel) {
const sttProvider = this.getProviderForModel('stt', sttModel);
if (sttProvider) {
// Update the provider's selected model
await providerSettingsRepository.upsert(sttProvider, {
selected_stt_model: sttModel
});
// Set as active STT provider
await providerSettingsRepository.setActiveProvider(sttProvider, 'stt');
}
} else {
// Deactivate all STT providers if no model selected
await providerSettingsRepository.setActiveProvider(null, 'stt');
} }
console.log(`[ModelStateService] State saved to database for user: ${userId}`); console.log(`[ModelStateService] State saved to database for user: ${userId}`);
@ -315,7 +435,7 @@ class ModelStateService extends EventEmitter {
} }
} }
setFirebaseVirtualKey(virtualKey) { async setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`); console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`);
this.state.apiKeys['openai-glass'] = virtualKey; this.state.apiKeys['openai-glass'] = virtualKey;
@ -337,8 +457,12 @@ class ModelStateService extends EventEmitter {
this._autoSelectAvailableModels(); this._autoSelectAvailableModels();
} }
this._saveState(); await this._saveState();
this._logCurrentSelection(); this._logCurrentSelection();
// Emit events to update UI
this.emit('state-updated', this.state);
this.emit('settings-updated');
} }
async setApiKey(provider, key) { async setApiKey(provider, key) {
@ -353,8 +477,8 @@ class ModelStateService extends EventEmitter {
this._autoSelectAvailableModels([]); this._autoSelectAvailableModels([]);
this._broadcastToAllWindows('model-state:updated', this.state); this.emit('state-updated', this.state);
this._broadcastToAllWindows('settings-updated'); this.emit('settings-updated');
} }
getApiKey(provider) { getApiKey(provider) {
@ -372,8 +496,8 @@ class ModelStateService extends EventEmitter {
await providerSettingsRepository.remove(provider); await providerSettingsRepository.remove(provider);
await this._saveState(); await this._saveState();
this._autoSelectAvailableModels([]); this._autoSelectAvailableModels([]);
this._broadcastToAllWindows('model-state:updated', this.state); this.emit('state-updated', this.state);
this._broadcastToAllWindows('settings-updated'); this.emit('settings-updated');
return true; return true;
} }
return false; return false;
@ -515,12 +639,21 @@ class ModelStateService extends EventEmitter {
if (type === 'llm' && modelId && modelId !== previousModelId) { if (type === 'llm' && modelId && modelId !== previousModelId) {
const provider = this.getProviderForModel('llm', modelId); const provider = this.getProviderForModel('llm', modelId);
if (provider === 'ollama') { if (provider === 'ollama') {
this._autoWarmUpOllamaModel(modelId, previousModelId); const localAIManager = require('./localAIManager');
if (localAIManager) {
console.log('[ModelStateService] Triggering Ollama model warm-up via LocalAIManager');
localAIManager.warmUpModel(modelId).catch(error => {
console.warn('[ModelStateService] Model warm-up failed:', error);
});
} else {
// fallback to old method
this._autoWarmUpOllamaModel(modelId, previousModelId);
}
} }
} }
this._broadcastToAllWindows('model-state:updated', this.state); this.emit('state-updated', this.state);
this._broadcastToAllWindows('settings-updated'); this.emit('settings-updated');
return true; return true;
} }
@ -587,7 +720,7 @@ class ModelStateService extends EventEmitter {
if (success) { if (success) {
const selectedModels = this.getSelectedModels(); const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) { if (!selectedModels.llm || !selectedModels.stt) {
this._broadcastToAllWindows('force-show-apikey-header'); this.emit('force-show-apikey-header');
} }
} }
return success; return success;

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,7 +5,6 @@ const authService = require('../common/services/authService');
const sessionRepository = require('../common/repositories/session'); const sessionRepository = require('../common/repositories/session');
const sttRepository = require('./stt/repositories'); const sttRepository = require('./stt/repositories');
const internalBridge = require('../../bridge/internalBridge'); const internalBridge = require('../../bridge/internalBridge');
const { EVENTS } = internalBridge;
class ListenService { class ListenService {
constructor() { constructor() {
@ -109,20 +108,24 @@ class ListenService {
switch (listenButtonText) { switch (listenButtonText) {
case 'Listen': case 'Listen':
console.log('[ListenService] changeSession to "Listen"'); console.log('[ListenService] changeSession to "Listen"');
internalBridge.emit('request-window-visibility', { name: 'listen', visible: true }); internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true });
await this.initializeSession(); await this.initializeSession();
listenWindow.webContents.send('session-state-changed', { isActive: true }); if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send('session-state-changed', { isActive: true });
}
break; break;
case 'Stop': case 'Stop':
console.log('[ListenService] changeSession to "Stop"'); console.log('[ListenService] changeSession to "Stop"');
await this.closeSession(); await this.closeSession();
listenWindow.webContents.send('session-state-changed', { isActive: false }); if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send('session-state-changed', { isActive: false });
}
break; break;
case 'Done': case 'Done':
console.log('[ListenService] changeSession to "Done"'); console.log('[ListenService] changeSession to "Done"');
internalBridge.emit('request-window-visibility', { name: 'listen', visible: false }); internalBridge.emit('window:requestVisibility', { name: 'listen', visible: false });
listenWindow.webContents.send('session-state-changed', { isActive: false }); listenWindow.webContents.send('session-state-changed', { isActive: false });
break; break;

View File

@ -55,17 +55,6 @@ class SttService {
} }
} }
async handleSendSystemAudioContent(data, mimeType) {
try {
await this.sendSystemAudioContent(data, mimeType);
this.sendToRenderer('system-audio-data', { data });
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
}
flushMyCompletion() { flushMyCompletion() {
const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim(); const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return; if (!this.modelInfo || !finalText) return;
@ -157,7 +146,7 @@ class SttService {
console.log('[SttService] Ignoring message - session already closed'); console.log('[SttService] Ignoring message - session already closed');
return; return;
} }
console.log('[SttService] handleMyMessage', message); // console.log('[SttService] handleMyMessage', message);
if (this.modelInfo.provider === 'whisper') { if (this.modelInfo.provider === 'whisper') {
// Whisper STT emits 'transcription' events with different structure // Whisper STT emits 'transcription' events with different structure
@ -178,10 +167,6 @@ class SttService {
'(NOISE)' '(NOISE)'
]; ];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern => const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern finalText.includes(pattern) || finalText === pattern
); );
@ -232,6 +217,38 @@ class SttService {
isFinal: false, isFinal: false,
timestamp: Date.now(), timestamp: Date.now(),
}); });
// Deepgram
} else if (this.modelInfo.provider === 'deepgram') {
const text = message.channel?.alternatives?.[0]?.transcript;
if (!text || text.trim().length === 0) return;
const isFinal = message.is_final;
console.log(`[SttService-Me-Deepgram] Received: isFinal=${isFinal}, text="${text}"`);
if (isFinal) {
// 최종 결과가 도착하면, 현재 진행중인 부분 발화는 비우고
// 최종 텍스트로 debounce를 실행합니다.
this.myCurrentUtterance = '';
this.debounceMyCompletion(text);
} else {
// 부분 결과(interim)인 경우, 화면에 실시간으로 업데이트합니다.
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null;
this.myCurrentUtterance = text;
const continuousText = (this.myCompletionBuffer + ' ' + this.myCurrentUtterance).trim();
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else { } else {
const type = message.type; const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -291,9 +308,6 @@ class SttService {
'(NOISE)' '(NOISE)'
]; ];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern => const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern finalText.includes(pattern) || finalText === pattern
); );
@ -345,6 +359,34 @@ class SttService {
isFinal: false, isFinal: false,
timestamp: Date.now(), timestamp: Date.now(),
}); });
// Deepgram
} else if (this.modelInfo.provider === 'deepgram') {
const text = message.channel?.alternatives?.[0]?.transcript;
if (!text || text.trim().length === 0) return;
const isFinal = message.is_final;
if (isFinal) {
this.theirCurrentUtterance = '';
this.debounceTheirCompletion(text);
} else {
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = null;
this.theirCurrentUtterance = text;
const continuousText = (this.theirCompletionBuffer + ' ' + this.theirCurrentUtterance).trim();
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else { } else {
const type = message.type; const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -431,10 +473,14 @@ class SttService {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
} }
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: data; payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(data, 'base64');
} else {
payload = data;
}
await this.mySttSession.sendRealtimeInput(payload); await this.mySttSession.sendRealtimeInput(payload);
} }
@ -452,9 +498,14 @@ class SttService {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
} }
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: data; payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(data, 'base64');
} else {
payload = data;
}
await this.theirSttSession.sendRealtimeInput(payload); await this.theirSttSession.sendRealtimeInput(payload);
} }
@ -547,9 +598,15 @@ class SttService {
if (this.theirSttSession) { if (this.theirSttSession) {
try { try {
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: base64Data; payload = { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(base64Data, 'base64');
} else {
payload = base64Data;
}
await this.theirSttSession.sendRealtimeInput(payload); await this.theirSttSession.sendRealtimeInput(payload);
} catch (err) { } catch (err) {
console.error('Error sending system audio:', err.message); console.error('Error sending system audio:', err.message);

View File

@ -6,8 +6,7 @@ const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window
// New imports for common services // New imports for common services
const modelStateService = require('../common/services/modelStateService'); const modelStateService = require('../common/services/modelStateService');
const ollamaService = require('../common/services/ollamaService'); const localAIManager = require('../common/services/localAIManager');
const whisperService = require('../common/services/whisperService');
const store = new Store({ const store = new Store({
name: 'pickle-glass-settings', name: 'pickle-glass-settings',
@ -54,17 +53,21 @@ async function setSelectedModel(type, modelId) {
return { success }; return { success };
} }
// Ollama facade functions // LocalAI facade functions
async function getOllamaStatus() { async function getOllamaStatus() {
return ollamaService.getStatus(); return localAIManager.getServiceStatus('ollama');
} }
async function ensureOllamaReady() { async function ensureOllamaReady() {
return ollamaService.ensureReady(); const status = await localAIManager.getServiceStatus('ollama');
if (!status.installed || !status.running) {
await localAIManager.startService('ollama');
}
return { success: true };
} }
async function shutdownOllama() { async function shutdownOllama() {
return ollamaService.shutdown(false); // false for graceful shutdown return localAIManager.stopService('ollama');
} }

View File

@ -10,6 +10,7 @@ class ShortcutsService {
this.mouseEventsIgnored = false; this.mouseEventsIgnored = false;
this.movementManager = null; this.movementManager = null;
this.windowPool = null; this.windowPool = null;
this.allWindowVisibility = true;
} }
initialize(movementManager, windowPool) { initialize(movementManager, windowPool) {
@ -22,6 +23,41 @@ class ShortcutsService {
console.log('[ShortcutsService] Initialized with dependencies and event listener.'); 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() { getDefaultKeybinds() {
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
return { return {
@ -72,32 +108,6 @@ class ShortcutsService {
return keybinds; return keybinds;
} }
async handleSaveShortcuts(newKeybinds) {
try {
await this.saveKeybinds(newKeybinds);
const shortcutEditor = this.windowPool.get('shortcut-settings');
if (shortcutEditor && !shortcutEditor.isDestroyed()) {
shortcutEditor.close(); // This will trigger re-registration on 'closed' event in windowManager
} else {
// If editor wasn't open, re-register immediately
await this.registerShortcuts();
}
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
// On failure, re-register old shortcuts to be safe
await this.registerShortcuts();
return { success: false, error: error.message };
}
}
async handleRestoreDefaults() {
const defaults = this.getDefaultKeybinds();
await this.saveKeybinds(defaults);
await this.registerShortcuts();
return defaults;
}
async saveKeybinds(newKeybinds) { async saveKeybinds(newKeybinds) {
const keybindsToSave = []; const keybindsToSave = [];
for (const action in newKeybinds) { for (const action in newKeybinds) {
@ -112,38 +122,22 @@ class ShortcutsService {
console.log(`[Shortcuts] Saved keybinds.`); console.log(`[Shortcuts] Saved keybinds.`);
} }
toggleAllWindowsVisibility(windowPool) { async toggleAllWindowsVisibility() {
const header = windowPool.get('header'); const targetVisibility = !this.allWindowVisibility;
if (!header) return; internalBridge.emit('window:requestToggleAllWindowsVisibility', {
targetVisibility: targetVisibility
});
if (header.isVisible()) { if (this.allWindowVisibility) {
this.lastVisibleWindows.clear(); await this.registerShortcuts(true);
} else {
windowPool.forEach((win, name) => { await this.registerShortcuts();
if (win && !win.isDestroyed() && win.isVisible()) {
this.lastVisibleWindows.add(name);
}
});
this.lastVisibleWindows.forEach(name => {
if (name === 'header') return;
const win = windowPool.get(name);
if (win && !win.isDestroyed()) win.hide();
});
header.hide();
return;
} }
this.lastVisibleWindows.forEach(name => { this.allWindowVisibility = !this.allWindowVisibility;
const win = windowPool.get(name);
if (win && !win.isDestroyed()) {
win.show();
}
});
} }
async registerShortcuts() { async registerShortcuts(registerOnlyToggleVisibility = false) {
if (!this.movementManager || !this.windowPool) { if (!this.movementManager || !this.windowPool) {
console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.'); console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
return; return;
@ -168,6 +162,14 @@ class ShortcutsService {
sendToRenderer('shortcuts-updated', keybinds); 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 --- // --- Hardcoded shortcuts ---
const isMac = process.platform === 'darwin'; const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl'; const modifier = isMac ? 'Cmd' : 'Ctrl';
@ -195,7 +197,7 @@ class ShortcutsService {
// --- User-configurable shortcuts --- // --- User-configurable shortcuts ---
if (header?.currentHeaderState === 'apikey') { if (header?.currentHeaderState === 'apikey') {
if (keybinds.toggleVisibility) { if (keybinds.toggleVisibility) {
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility(this.windowPool)); globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
} }
console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.'); console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.');
return; return;
@ -208,7 +210,7 @@ class ShortcutsService {
let callback; let callback;
switch(action) { switch(action) {
case 'toggleVisibility': case 'toggleVisibility':
callback = () => this.toggleAllWindowsVisibility(this.windowPool); callback = () => this.toggleAllWindowsVisibility();
break; break;
case 'nextStep': case 'nextStep':
callback = () => askService.toggleAskButton(true); callback = () => askService.toggleAskButton(true);
@ -282,4 +284,7 @@ class ShortcutsService {
} }
} }
module.exports = new ShortcutsService();
const shortcutsService = new ShortcutsService();
module.exports = shortcutsService;

View File

@ -31,11 +31,20 @@ contextBridge.exposeInMainWorld('api', {
apiKeyHeader: { apiKeyHeader: {
// Model & Provider Management // Model & Provider Management
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'), getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'), // LocalAI 통합 API
getLocalAIStatus: (service) => ipcRenderer.invoke('localai:get-status', service),
installLocalAI: (service, options) => ipcRenderer.invoke('localai:install', { service, options }),
startLocalAIService: (service) => ipcRenderer.invoke('localai:start-service', service),
stopLocalAIService: (service) => ipcRenderer.invoke('localai:stop-service', service),
installLocalAIModel: (service, modelId, options) => ipcRenderer.invoke('localai:install-model', { service, modelId, options }),
getInstalledModels: (service) => ipcRenderer.invoke('localai:get-installed-models', service),
// Legacy support (호환성 위해 유지)
getOllamaStatus: () => ipcRenderer.invoke('localai:get-status', 'ollama'),
getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'), getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'), ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
installOllama: () => ipcRenderer.invoke('ollama:install'), installOllama: () => ipcRenderer.invoke('localai:install', { service: 'ollama' }),
startOllamaService: () => ipcRenderer.invoke('ollama:start-service'), startOllamaService: () => ipcRenderer.invoke('localai:start-service', 'ollama'),
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName), pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId), downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data), validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
@ -47,21 +56,25 @@ contextBridge.exposeInMainWorld('api', {
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y), moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
// Listeners // Listeners
onOllamaInstallProgress: (callback) => ipcRenderer.on('ollama:install-progress', callback), // LocalAI 통합 이벤트 리스너
removeOnOllamaInstallProgress: (callback) => ipcRenderer.removeListener('ollama:install-progress', callback), onLocalAIProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
onceOllamaInstallComplete: (callback) => ipcRenderer.once('ollama:install-complete', callback), removeOnLocalAIProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
removeOnceOllamaInstallComplete: (callback) => ipcRenderer.removeListener('ollama:install-complete', callback), onLocalAIComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback), removeOnLocalAIComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback),
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback), onLocalAIError: (callback) => ipcRenderer.on('localai:error-notification', callback),
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), removeOnLocalAIError: (callback) => ipcRenderer.removeListener('localai:error-notification', callback),
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), onLocalAIModelReady: (callback) => ipcRenderer.on('localai:model-ready', callback),
removeOnLocalAIModelReady: (callback) => ipcRenderer.removeListener('localai:model-ready', callback),
// Remove all listeners (for cleanup) // Remove all listeners (for cleanup)
removeAllListeners: () => { removeAllListeners: () => {
ipcRenderer.removeAllListeners('whisper:download-progress'); // LocalAI 통합 이벤트
ipcRenderer.removeAllListeners('ollama:install-progress'); ipcRenderer.removeAllListeners('localai:install-progress');
ipcRenderer.removeAllListeners('ollama:pull-progress'); ipcRenderer.removeAllListeners('localai:installation-complete');
ipcRenderer.removeAllListeners('ollama:install-complete'); ipcRenderer.removeAllListeners('localai:error-notification');
ipcRenderer.removeAllListeners('localai:model-ready');
ipcRenderer.removeAllListeners('localai:service-status-changed');
} }
}, },
@ -98,11 +111,14 @@ contextBridge.exposeInMainWorld('api', {
// Settings Window Management // Settings Window Management
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'), cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
showSettingsWindow: (bounds) => ipcRenderer.send('show-settings-window', bounds), showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'), hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// Generic invoke (for dynamic channel names) // Generic invoke (for dynamic channel names)
invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args), // invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
sendListenButtonClick: (listenButtonText) => ipcRenderer.invoke('listen:changeSession', listenButtonText),
sendAskButtonClick: () => ipcRenderer.invoke('ask:toggleAskButton'),
sendToggleAllWindowsVisibility: () => ipcRenderer.invoke('shortcut:toggleAllWindowsVisibility'),
// Listeners // Listeners
onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback), onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback),
@ -218,8 +234,8 @@ contextBridge.exposeInMainWorld('api', {
setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled), setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'), getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'), toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
getCurrentShortcuts: () => ipcRenderer.invoke('get-current-shortcuts'), getCurrentShortcuts: () => ipcRenderer.invoke('settings:getCurrentShortcuts'),
openShortcutEditor: () => ipcRenderer.invoke('open-shortcut-editor'), openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'),
// Window Management // Window Management
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction), moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
@ -241,29 +257,27 @@ contextBridge.exposeInMainWorld('api', {
removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback), removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback), onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback), removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
onWhisperDownloadProgress: (callback) => ipcRenderer.on('whisper:download-progress', callback), // 통합 LocalAI 이벤트 사용
removeOnWhisperDownloadProgress: (callback) => ipcRenderer.removeListener('whisper:download-progress', callback), onLocalAIInstallProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
onOllamaPullProgress: (callback) => ipcRenderer.on('ollama:pull-progress', callback), removeOnLocalAIInstallProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
removeOnOllamaPullProgress: (callback) => ipcRenderer.removeListener('ollama:pull-progress', callback) onLocalAIInstallationComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
removeOnLocalAIInstallationComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback)
}, },
// src/ui/settings/ShortCutSettingsView.js // src/ui/settings/ShortCutSettingsView.js
shortcutSettingsView: { shortcutSettingsView: {
// Shortcut Management // Shortcut Management
saveShortcuts: (shortcuts) => ipcRenderer.invoke('save-shortcuts', shortcuts), saveShortcuts: (shortcuts) => ipcRenderer.invoke('shortcut:saveShortcuts', shortcuts),
getDefaultShortcuts: () => ipcRenderer.invoke('get-default-shortcuts'), getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'),
closeShortcutEditor: () => ipcRenderer.send('close-shortcut-editor'), closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'),
// Listeners // Listeners
onLoadShortcuts: (callback) => ipcRenderer.on('load-shortcuts', callback), onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback),
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('load-shortcuts', callback) removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback)
}, },
// src/ui/app/content.html inline scripts // src/ui/app/content.html inline scripts
content: { content: {
// Animation Management
// sendAnimationFinished: () => ipcRenderer.send('animation-finished'),
// Listeners // Listeners
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback), onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback), removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),

View File

@ -1092,6 +1092,9 @@ export class ApiKeyHeader extends LitElement {
this.requestUpdate(); this.requestUpdate();
const progressHandler = (event, data) => { const progressHandler = (event, data) => {
// 통합 LocalAI 이벤트에서 Ollama 진행률만 처리
if (data.service !== 'ollama') return;
let baseProgress = 0; let baseProgress = 0;
let stageTotal = 0; let stageTotal = 0;
@ -1137,17 +1140,21 @@ export class ApiKeyHeader extends LitElement {
} }
}, 15000); // 15 second timeout }, 15000); // 15 second timeout
const completionHandler = async (event, result) => { const completionHandler = async (event, data) => {
// 통합 LocalAI 이벤트에서 Ollama 완료만 처리
if (data.service !== 'ollama') return;
if (operationCompleted) return; if (operationCompleted) return;
operationCompleted = true; operationCompleted = true;
clearTimeout(completionTimeout); clearTimeout(completionTimeout);
window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
await this._handleOllamaSetupCompletion(result.success, result.error); // installation-complete 이벤트는 성공을 의미
await this._handleOllamaSetupCompletion(true);
}; };
window.api.apiKeyHeader.onceOllamaInstallComplete(completionHandler); // 통합 LocalAI 이벤트 사용
window.api.apiKeyHeader.onOllamaInstallProgress(progressHandler); window.api.apiKeyHeader.onLocalAIComplete(completionHandler);
window.api.apiKeyHeader.onLocalAIProgress(progressHandler);
try { try {
let result; let result;
@ -1173,8 +1180,8 @@ export class ApiKeyHeader extends LitElement {
operationCompleted = true; operationCompleted = true;
clearTimeout(completionTimeout); clearTimeout(completionTimeout);
console.error('[ApiKeyHeader] Ollama setup failed:', error); console.error('[ApiKeyHeader] Ollama setup failed:', error);
window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
window.api.apiKeyHeader.removeOnceOllamaInstallComplete(completionHandler); window.api.apiKeyHeader.removeOnLocalAIComplete(completionHandler);
await this._handleOllamaSetupCompletion(false, error.message); await this._handleOllamaSetupCompletion(false, error.message);
} }
} }
@ -1304,7 +1311,7 @@ export class ApiKeyHeader extends LitElement {
// Create robust progress handler with timeout protection // Create robust progress handler with timeout protection
progressHandler = (event, data) => { progressHandler = (event, data) => {
if (data.model === modelName && !this._isOperationCancelled(modelName)) { if (data.service === 'ollama' && data.model === modelName && !this._isOperationCancelled(modelName)) {
const progress = Math.round(Math.max(0, Math.min(100, data.progress || 0))); const progress = Math.round(Math.max(0, Math.min(100, data.progress || 0)));
if (progress !== this.installProgress) { if (progress !== this.installProgress) {
@ -1315,8 +1322,8 @@ export class ApiKeyHeader extends LitElement {
} }
}; };
// Set up progress tracking // Set up progress tracking - 통합 LocalAI 이벤트 사용
window.api.apiKeyHeader.onOllamaPullProgress(progressHandler); window.api.apiKeyHeader.onLocalAIProgress(progressHandler);
// Execute the model pull with timeout // Execute the model pull with timeout
const installPromise = window.api.apiKeyHeader.pullOllamaModel(modelName); const installPromise = window.api.apiKeyHeader.pullOllamaModel(modelName);
@ -1346,7 +1353,7 @@ export class ApiKeyHeader extends LitElement {
} finally { } finally {
// Comprehensive cleanup // Comprehensive cleanup
if (progressHandler) { if (progressHandler) {
window.api.apiKeyHeader.removeOnOllamaPullProgress(progressHandler); window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
} }
this.installingModel = null; this.installingModel = null;
@ -1376,17 +1383,17 @@ export class ApiKeyHeader extends LitElement {
let progressHandler = null; let progressHandler = null;
try { try {
// Set up robust progress listener // Set up robust progress listener - 통합 LocalAI 이벤트 사용
progressHandler = (event, { modelId: id, progress }) => { progressHandler = (event, data) => {
if (id === modelId) { if (data.service === 'whisper' && data.model === modelId) {
const cleanProgress = Math.round(Math.max(0, Math.min(100, progress || 0))); const cleanProgress = Math.round(Math.max(0, Math.min(100, data.progress || 0)));
this.whisperInstallingModels = { ...this.whisperInstallingModels, [modelId]: cleanProgress }; this.whisperInstallingModels = { ...this.whisperInstallingModels, [modelId]: cleanProgress };
console.log(`[ApiKeyHeader] Whisper download progress: ${cleanProgress}% for ${modelId}`); console.log(`[ApiKeyHeader] Whisper download progress: ${cleanProgress}% for ${modelId}`);
this.requestUpdate(); this.requestUpdate();
} }
}; };
window.api.apiKeyHeader.onWhisperDownloadProgress(progressHandler); window.api.apiKeyHeader.onLocalAIProgress(progressHandler);
// Start download with timeout protection // Start download with timeout protection
const downloadPromise = window.api.apiKeyHeader.downloadWhisperModel(modelId); const downloadPromise = window.api.apiKeyHeader.downloadWhisperModel(modelId);
@ -1413,7 +1420,7 @@ export class ApiKeyHeader extends LitElement {
} finally { } finally {
// Cleanup // Cleanup
if (progressHandler) { if (progressHandler) {
window.api.apiKeyHeader.removeOnWhisperDownloadProgress(progressHandler); window.api.apiKeyHeader.removeOnLocalAIProgress(progressHandler);
} }
delete this.whisperInstallingModels[modelId]; delete this.whisperInstallingModels[modelId];
this.requestUpdate(); this.requestUpdate();

View File

@ -2,7 +2,6 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class MainHeader extends LitElement { export class MainHeader extends LitElement {
static properties = { static properties = {
// isSessionActive: { type: Boolean, state: true },
isTogglingSession: { type: Boolean, state: true }, isTogglingSession: { type: Boolean, state: true },
shortcuts: { type: Object, state: true }, shortcuts: { type: Object, state: true },
listenSessionStatus: { type: String, state: true }, listenSessionStatus: { type: String, state: true },
@ -515,30 +514,12 @@ export class MainHeader extends LitElement {
} }
} }
invoke(channel, ...args) {
if (this.wasJustDragged) return;
if (window.api) {
window.api.mainHeader.invoke(channel, ...args);
}
// return Promise.resolve();
}
showSettingsWindow(element) { showSettingsWindow(element) {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
if (window.api) { if (window.api) {
console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`); console.log(`[MainHeader] showSettingsWindow called at ${Date.now()}`);
window.api.mainHeader.showSettingsWindow();
window.api.mainHeader.cancelHideSettingsWindow();
if (element) {
const { left, top, width, height } = element.getBoundingClientRect();
window.api.mainHeader.showSettingsWindow({
x: left,
y: top,
width,
height,
});
}
} }
} }
@ -559,9 +540,10 @@ export class MainHeader extends LitElement {
this.isTogglingSession = true; this.isTogglingSession = true;
try { try {
const channel = 'listen:changeSession';
const listenButtonText = this._getListenButtonText(this.listenSessionStatus); const listenButtonText = this._getListenButtonText(this.listenSessionStatus);
await this.invoke(channel, listenButtonText); if (window.api) {
await window.api.mainHeader.sendListenButtonClick(listenButtonText);
}
} catch (error) { } catch (error) {
console.error('IPC invoke for session change failed:', error); console.error('IPC invoke for session change failed:', error);
this.isTogglingSession = false; this.isTogglingSession = false;
@ -572,13 +554,26 @@ export class MainHeader extends LitElement {
if (this.wasJustDragged) return; if (this.wasJustDragged) return;
try { try {
const channel = 'ask:toggleAskButton'; if (window.api) {
await this.invoke(channel); await window.api.mainHeader.sendAskButtonClick();
}
} catch (error) { } catch (error) {
console.error('IPC invoke for ask button failed:', error); console.error('IPC invoke for ask button failed:', error);
} }
} }
async _handleToggleAllWindowsVisibility() {
if (this.wasJustDragged) return;
try {
if (window.api) {
await window.api.mainHeader.sendToggleAllWindowsVisibility();
}
} catch (error) {
console.error('IPC invoke for all windows visibility button failed:', error);
}
}
renderShortcut(accelerator) { renderShortcut(accelerator) {
if (!accelerator) return html``; if (!accelerator) return html``;
@ -656,7 +651,7 @@ export class MainHeader extends LitElement {
</div> </div>
</div> </div>
<div class="header-actions" @click=${() => this.invoke('toggle-all-windows-visibility')}> <div class="header-actions" @click=${() => this._handleToggleAllWindowsVisibility()}>
<div class="action-text"> <div class="action-text">
<div class="action-text-content">Show/Hide</div> <div class="action-text-content">Show/Hide</div>
</div> </div>

View File

@ -575,19 +575,50 @@ export class SettingsView extends LitElement {
this.requestUpdate(); this.requestUpdate();
} }
async loadLocalAIStatus() {
try {
// Load Ollama status
const ollamaStatus = await window.api.settingsView.getOllamaStatus();
if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || [];
}
// Load Whisper models status only if Whisper is enabled
if (this.apiKeys?.whisper === 'local') {
const whisperModelsResult = await window.api.settingsView.getWhisperInstalledModels();
if (whisperModelsResult?.success) {
const installedWhisperModels = whisperModelsResult.models;
if (this.providerConfig?.whisper) {
this.providerConfig.whisper.sttModels.forEach(m => {
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
if (installedInfo) {
m.installed = installedInfo.installed;
}
});
}
}
}
// Trigger UI update
this.requestUpdate();
} catch (error) {
console.error('Error loading LocalAI status:', error);
}
}
//////// after_modelStateService //////// //////// after_modelStateService ////////
async loadInitialData() { async loadInitialData() {
if (!window.api) return; if (!window.api) return;
this.isLoading = true; this.isLoading = true;
try { try {
const [userState, modelSettings, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = await Promise.all([ // Load essential data first
const [userState, modelSettings, presets, contentProtection, shortcuts] = await Promise.all([
window.api.settingsView.getCurrentUser(), window.api.settingsView.getCurrentUser(),
window.api.settingsView.getModelSettings(), // Facade call window.api.settingsView.getModelSettings(), // Facade call
window.api.settingsView.getPresets(), window.api.settingsView.getPresets(),
window.api.settingsView.getContentProtectionStatus(), window.api.settingsView.getContentProtectionStatus(),
window.api.settingsView.getCurrentShortcuts(), window.api.settingsView.getCurrentShortcuts()
window.api.settingsView.getOllamaStatus(),
window.api.settingsView.getWhisperInstalledModels()
]); ]);
if (userState && userState.isLoggedIn) this.firebaseUser = userState; if (userState && userState.isLoggedIn) this.firebaseUser = userState;
@ -609,23 +640,9 @@ export class SettingsView extends LitElement {
const firstUserPreset = this.presets.find(p => p.is_default === 0); const firstUserPreset = this.presets.find(p => p.is_default === 0);
if (firstUserPreset) this.selectedPreset = firstUserPreset; if (firstUserPreset) this.selectedPreset = firstUserPreset;
} }
// Ollama status
if (ollamaStatus?.success) { // Load LocalAI status asynchronously to improve initial load time
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running }; this.loadLocalAIStatus();
this.ollamaModels = ollamaStatus.models || [];
}
// Whisper status
if (whisperModelsResult?.success) {
const installedWhisperModels = whisperModelsResult.models;
if (this.providerConfig.whisper) {
this.providerConfig.whisper.sttModels.forEach(m => {
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
if (installedInfo) {
m.installed = installedInfo.installed;
}
});
}
}
} catch (error) { } catch (error) {
console.error('Error loading initial settings data:', error); console.error('Error loading initial settings data:', error);
} finally { } finally {
@ -779,16 +796,16 @@ export class SettingsView extends LitElement {
this.installingModels = { ...this.installingModels, [modelName]: 0 }; this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.requestUpdate(); this.requestUpdate();
// 진행률 이벤트 리스너 설정 // 진행률 이벤트 리스너 설정 - 통합 LocalAI 이벤트 사용
const progressHandler = (event, data) => { const progressHandler = (event, data) => {
if (data.modelId === modelName) { if (data.service === 'ollama' && data.model === modelName) {
this.installingModels = { ...this.installingModels, [modelName]: data.progress }; this.installingModels = { ...this.installingModels, [modelName]: data.progress || 0 };
this.requestUpdate(); this.requestUpdate();
} }
}; };
// 진행률 이벤트 리스너 등록 // 통합 LocalAI 이벤트 리스너 등록
window.api.settingsView.onOllamaPullProgress(progressHandler); window.api.settingsView.onLocalAIInstallProgress(progressHandler);
try { try {
const result = await window.api.settingsView.pullOllamaModel(modelName); const result = await window.api.settingsView.pullOllamaModel(modelName);
@ -805,8 +822,8 @@ export class SettingsView extends LitElement {
throw new Error(result.error || 'Installation failed'); throw new Error(result.error || 'Installation failed');
} }
} finally { } finally {
// 진행률 이벤트 리스너 제거 // 통합 LocalAI 이벤트 리스너 제거
window.api.settingsView.removeOnOllamaPullProgress(progressHandler); window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
} }
} catch (error) { } catch (error) {
console.error(`[SettingsView] Error installing model ${modelName}:`, error); console.error(`[SettingsView] Error installing model ${modelName}:`, error);
@ -821,34 +838,52 @@ export class SettingsView extends LitElement {
this.requestUpdate(); this.requestUpdate();
try { try {
// Set up progress listener // Set up progress listener - 통합 LocalAI 이벤트 사용
const progressHandler = (event, { modelId: id, progress }) => { const progressHandler = (event, data) => {
if (id === modelId) { if (data.service === 'whisper' && data.model === modelId) {
this.installingModels = { ...this.installingModels, [modelId]: progress }; this.installingModels = { ...this.installingModels, [modelId]: data.progress || 0 };
this.requestUpdate(); this.requestUpdate();
} }
}; };
window.api.settingsView.onWhisperDownloadProgress(progressHandler); window.api.settingsView.onLocalAIInstallProgress(progressHandler);
// Start download // Start download
const result = await window.api.settingsView.downloadWhisperModel(modelId); const result = await window.api.settingsView.downloadWhisperModel(modelId);
if (result.success) { if (result.success) {
// Update the model's installed status
if (this.providerConfig?.whisper?.sttModels) {
const modelInfo = this.providerConfig.whisper.sttModels.find(m => m.id === modelId);
if (modelInfo) {
modelInfo.installed = true;
}
}
// Remove from installing models
delete this.installingModels[modelId];
this.requestUpdate();
// Reload LocalAI status to get fresh data
await this.loadLocalAIStatus();
// Auto-select the model after download // Auto-select the model after download
await this.selectModel('stt', modelId); await this.selectModel('stt', modelId);
} else { } else {
// Remove from installing models on failure too
delete this.installingModels[modelId];
this.requestUpdate();
alert(`Failed to download Whisper model: ${result.error}`); alert(`Failed to download Whisper model: ${result.error}`);
} }
// Cleanup // Cleanup
window.api.settingsView.removeOnWhisperDownloadProgress(progressHandler); window.api.settingsView.removeOnLocalAIInstallProgress(progressHandler);
} catch (error) { } catch (error) {
console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error); console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error);
alert(`Error downloading ${modelId}: ${error.message}`); // Remove from installing models on error
} finally {
delete this.installingModels[modelId]; delete this.installingModels[modelId];
this.requestUpdate(); this.requestUpdate();
alert(`Error downloading ${modelId}: ${error.message}`);
} }
} }
@ -862,12 +897,6 @@ export class SettingsView extends LitElement {
return null; return null;
} }
async handleWhisperModelSelect(modelId) {
if (!modelId) return;
// Select the model (will trigger download if needed)
await this.selectModel('stt', modelId);
}
handleUsePicklesKey(e) { handleUsePicklesKey(e) {
e.preventDefault() e.preventDefault()
@ -879,7 +908,7 @@ export class SettingsView extends LitElement {
//////// after_modelStateService //////// //////// after_modelStateService ////////
openShortcutEditor() { openShortcutEditor() {
window.api.settingsView.openShortcutEditor(); window.api.settingsView.openShortcutSettingsWindow();
} }
connectedCallback() { connectedCallback() {
@ -927,7 +956,8 @@ export class SettingsView extends LitElement {
this.firebaseUser = null; this.firebaseUser = null;
} }
this.loadAutoUpdateSetting(); this.loadAutoUpdateSetting();
this.requestUpdate(); // Reload model settings when user state changes (Firebase login/logout)
this.loadInitialData();
}; };
this._settingsUpdatedListener = (event, settings) => { this._settingsUpdatedListener = (event, settings) => {
@ -1019,13 +1049,7 @@ export class SettingsView extends LitElement {
window.api.settingsView.hideSettingsWindow(); window.api.settingsView.hideSettingsWindow();
} }
// getMainShortcuts() {
// return [
// { name: 'Show / Hide', key: '\\' },
// { name: 'Ask Anything', key: '↵' },
// { name: 'Scroll AI Response', key: '↕' }
// ];
// }
getMainShortcuts() { getMainShortcuts() {
return [ return [
{ name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility }, { name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility },
@ -1198,12 +1222,7 @@ export class SettingsView extends LitElement {
} }
if (id === 'whisper') { if (id === 'whisper') {
// Special UI for Whisper with model selection // Simplified UI for Whisper without model selection
const whisperModels = config.sttModels || [];
const selectedWhisperModel = this.selectedStt && this.getProviderForModel('stt', this.selectedStt) === 'whisper'
? this.selectedStt
: null;
return html` return html`
<div class="provider-key-group"> <div class="provider-key-group">
<label>${config.name} (Local STT)</label> <label>${config.name} (Local STT)</label>
@ -1211,51 +1230,6 @@ export class SettingsView extends LitElement {
<div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8); margin-bottom: 8px;"> <div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8); margin-bottom: 8px;">
Whisper is enabled Whisper is enabled
</div> </div>
<!-- Whisper Model Selection Dropdown -->
<label style="font-size: 10px; margin-top: 8px;">Select Model:</label>
<select
class="model-dropdown"
style="width: 100%; padding: 6px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; font-size: 11px; margin-bottom: 8px;"
@change=${(e) => this.handleWhisperModelSelect(e.target.value)}
.value=${selectedWhisperModel || ''}
>
<option value="">Choose a model...</option>
${whisperModels.map(model => {
const isInstalling = this.installingModels[model.id] !== undefined;
const progress = this.installingModels[model.id] || 0;
let statusText = '';
if (isInstalling) {
statusText = ` (Downloading ${progress}%)`;
} else if (model.installed) {
statusText = ' (Installed)';
}
return html`
<option value="${model.id}" ?disabled=${isInstalling}>
${model.name}${statusText}
</option>
`;
})}
</select>
${Object.entries(this.installingModels).map(([modelId, progress]) => {
if (modelId.startsWith('whisper-') && progress !== undefined) {
return html`
<div style="margin: 8px 0;">
<div style="font-size: 10px; color: rgba(255,255,255,0.7); margin-bottom: 4px;">
Downloading ${modelId}...
</div>
<div class="install-progress" style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden;">
<div class="install-progress-bar" style="height: 100%; background: rgba(0, 122, 255, 0.8); width: ${progress}%; transition: width 0.3s ease;"></div>
</div>
</div>
`;
}
return null;
})}
<button class="settings-button full-width danger" @click=${() => this.handleClearKey(id)}> <button class="settings-button full-width danger" @click=${() => this.handleClearKey(id)}>
Disable Whisper Disable Whisper
</button> </button>
@ -1337,6 +1311,9 @@ export class SettingsView extends LitElement {
<div class="model-list"> <div class="model-list">
${this.availableSttModels.map(model => { ${this.availableSttModels.map(model => {
const isWhisper = this.getProviderForModel('stt', model.id) === 'whisper'; const isWhisper = this.getProviderForModel('stt', model.id) === 'whisper';
const whisperModel = isWhisper && this.providerConfig?.whisper?.sttModels
? this.providerConfig.whisper.sttModels.find(m => m.id === model.id)
: null;
const isInstalling = this.installingModels[model.id] !== undefined; const isInstalling = this.installingModels[model.id] !== undefined;
const installProgress = this.installingModels[model.id] || 0; const installProgress = this.installingModels[model.id] || 0;
@ -1344,10 +1321,16 @@ export class SettingsView extends LitElement {
<div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}" <div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}"
@click=${() => this.selectModel('stt', model.id)}> @click=${() => this.selectModel('stt', model.id)}>
<span>${model.name}</span> <span>${model.name}</span>
${isWhisper && isInstalling ? html` ${isWhisper ? html`
<div class="install-progress"> ${isInstalling ? html`
<div class="install-progress-bar" style="width: ${installProgress}%"></div> <div class="install-progress">
</div> <div class="install-progress-bar" style="width: ${installProgress}%"></div>
</div>
` : whisperModel?.installed ? html`
<span class="model-status installed"> Installed</span>
` : html`
<span class="model-status not-installed">Not Installed</span>
`}
` : ''} ` : ''}
</div> </div>
`; `;

View File

@ -179,7 +179,7 @@ export class ShortcutSettingsView extends LitElement {
handleClose() { handleClose() {
if (!window.api) return; if (!window.api) return;
window.api.shortcutSettingsView.closeShortcutEditor(); window.api.shortcutSettingsView.closeShortcutSettingsWindow();
} }
async handleResetToDefault() { async handleResetToDefault() {

View File

@ -142,7 +142,14 @@ class WindowLayoutManager {
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY); const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY, workAreaX, workAreaY);
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY); this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY); const settings = this.windowPool.get('settings');
if (settings && !settings.isDestroyed() && settings.isVisible()) {
const settingPos = this.calculateSettingsWindowPosition();
if (settingPos) {
const { width, height } = settings.getBounds();
settings.setBounds({ x: settingPos.x, y: settingPos.y, width, height });
}
}
} }
/** /**
@ -234,58 +241,54 @@ class WindowLayoutManager {
} }
} }
positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) { /**
* @returns {{x: number, y: number} | null}
*/
calculateSettingsWindowPosition() {
const header = this.windowPool.get('header');
const settings = this.windowPool.get('settings'); const settings = this.windowPool.get('settings');
if (!settings?.getBounds || !settings.isVisible()) return;
if (settings.__lockedByButton) { if (!header || header.isDestroyed() || !settings || settings.isDestroyed()) {
const headerDisplay = getCurrentDisplay(this.windowPool.get('header')); return null;
const settingsDisplay = getCurrentDisplay(settings);
if (headerDisplay.id !== settingsDisplay.id) {
settings.__lockedByButton = false;
} else {
return;
}
} }
const headerBounds = header.getBounds();
const settingsBounds = settings.getBounds(); const settingsBounds = settings.getBounds();
const display = getCurrentDisplay(header);
const { x: workAreaX, y: workAreaY, width: screenWidth, height: screenHeight } = display.workArea;
const PAD = 5; const PAD = 5;
const buttonPadding = 17; const buttonPadding = 170;
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
let y = headerBounds.y + headerBounds.height + PAD;
const otherVisibleWindows = []; const x = headerBounds.x + headerBounds.width - settingsBounds.width + buttonPadding;
['listen', 'ask'].forEach(name => { const y = headerBounds.y + headerBounds.height + PAD;
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 }; const clampedX = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds)); const clampedY = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
if (hasOverlap) { return { x: Math.round(clampedX), y: Math.round(clampedY) };
x = headerBounds.x + headerBounds.width + PAD; }
y = headerBounds.y;
if (x + settingsBounds.width > screenWidth - 10) { positionShortcutSettingsWindow() {
x = headerBounds.x - settingsBounds.width - PAD; const header = this.windowPool.get('header');
} const shortcutSettings = this.windowPool.get('shortcut-settings');
if (x < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding; if (!header || header.isDestroyed() || !shortcutSettings || shortcutSettings.isDestroyed()) {
y = headerBounds.y - settingsBounds.height - PAD; return;
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)); const headerBounds = header.getBounds();
y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y)); const shortcutBounds = shortcutSettings.getBounds();
const display = getCurrentDisplay(header);
const { workArea } = display;
settings.setBounds({ x: Math.round(x), y: Math.round(y) }); let newX = Math.round(headerBounds.x + (headerBounds.width / 2) - (shortcutBounds.width / 2));
settings.moveTop(); let newY = Math.round(headerBounds.y);
newX = Math.max(workArea.x, Math.min(newX, workArea.x + workArea.width - shortcutBounds.width));
newY = Math.max(workArea.y, Math.min(newY, workArea.y + workArea.height - shortcutBounds.height));
shortcutSettings.setBounds({ x: newX, y: newY, width: shortcutBounds.width, height: shortcutBounds.height });
} }
/** /**

View File

@ -5,7 +5,6 @@ const path = require('node:path');
const os = require('os'); const os = require('os');
const shortcutsService = require('../features/shortcuts/shortcutsService'); const shortcutsService = require('../features/shortcuts/shortcutsService');
const internalBridge = require('../bridge/internalBridge'); const internalBridge = require('../bridge/internalBridge');
const { EVENTS } = internalBridge;
const permissionRepository = require('../features/common/repositories/permission'); const permissionRepository = require('../features/common/repositories/permission');
/* ────────────────[ GLASS BYPASS ]─────────────── */ /* ────────────────[ GLASS BYPASS ]─────────────── */
@ -30,9 +29,6 @@ if (shouldUseLiquidGlass) {
/* ────────────────[ GLASS BYPASS ]─────────────── */ /* ────────────────[ GLASS BYPASS ]─────────────── */
let isContentProtectionOn = true; let isContentProtectionOn = true;
let currentDisplayId = null;
let mouseEventsIgnored = false;
let lastVisibleWindows = new Set(['header']); let lastVisibleWindows = new Set(['header']);
const HEADER_HEIGHT = 47; const HEADER_HEIGHT = 47;
const DEFAULT_WINDOW_WIDTH = 353; const DEFAULT_WINDOW_WIDTH = 353;
@ -42,9 +38,7 @@ const windowPool = new Map();
let settingsHideTimer = null; let settingsHideTimer = null;
let selectedCaptureSourceId = null;
// let shortcutEditorWindow = null;
let layoutManager = null; let layoutManager = null;
function updateLayout() { function updateLayout() {
if (layoutManager) { if (layoutManager) {
@ -92,20 +86,69 @@ function fadeWindow(win, from, to, duration = FADE_DURATION, onComplete) {
}, 1000 / FADE_FPS); }, 1000 / FADE_FPS);
} }
const showSettingsWindow = () => {
internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
};
function setupAnimationController(windowPool, layoutManager, movementManager) { const hideSettingsWindow = () => {
internalBridge.on('request-window-visibility', ({ name, visible }) => { internalBridge.emit('window:requestVisibility', { name: 'settings', visible: false });
};
const cancelHideSettingsWindow = () => {
internalBridge.emit('window:requestVisibility', { name: 'settings', visible: true });
};
function setupWindowController(windowPool, layoutManager, movementManager) {
internalBridge.on('window:requestVisibility', ({ name, visible }) => {
handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, visible); handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, visible);
}); });
internalBridge.on('window:requestToggleAllWindowsVisibility', ({ targetVisibility }) => {
changeAllWindowsVisibility(windowPool, targetVisibility);
});
} }
function changeAllWindowsVisibility(windowPool, targetVisibility) {
const header = windowPool.get('header');
if (!header) return;
if (typeof targetVisibility === 'boolean' &&
header.isVisible() === targetVisibility) {
return;
}
if (header.isVisible()) {
lastVisibleWindows.clear();
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed() && win.isVisible()) {
lastVisibleWindows.add(name);
}
});
lastVisibleWindows.forEach(name => {
if (name === 'header') return;
const win = windowPool.get(name);
if (win && !win.isDestroyed()) win.hide();
});
header.hide();
return;
}
lastVisibleWindows.forEach(name => {
const win = windowPool.get(name);
if (win && !win.isDestroyed())
win.show();
});
}
/** /**
* *
* @param {Map<string, BrowserWindow>} windowPool * @param {Map<string, BrowserWindow>} windowPool
* @param {WindowLayoutManager} layoutManager * @param {WindowLayoutManager} layoutManager
* @param {SmoothMovementManager} movementManager * @param {SmoothMovementManager} movementManager
* @param {'listen' | 'ask'} name * @param {'listen' | 'ask' | 'settings' | 'shortcut-settings'} name
* @param {boolean} shouldBeVisible * @param {boolean} shouldBeVisible
*/ */
async function handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, shouldBeVisible) { async function handleWindowVisibilityRequest(windowPool, layoutManager, movementManager, name, shouldBeVisible) {
@ -117,94 +160,171 @@ async function handleWindowVisibilityRequest(windowPool, layoutManager, movement
return; return;
} }
const isCurrentlyVisible = win.isVisible(); if (name !== 'settings') {
if (isCurrentlyVisible === shouldBeVisible) { const isCurrentlyVisible = win.isVisible();
console.log(`[WindowManager] Window '${name}' is already in the desired state.`); if (isCurrentlyVisible === shouldBeVisible) {
console.log(`[WindowManager] Window '${name}' is already in the desired state.`);
return;
}
}
const disableClicks = (selectedWindow) => {
for (const [name, win] of windowPool) {
if (win !== selectedWindow && !win.isDestroyed()) {
win.setIgnoreMouseEvents(true, { forward: true });
}
}
};
const restoreClicks = () => {
for (const [, win] of windowPool) {
if (!win.isDestroyed()) win.setIgnoreMouseEvents(false);
}
};
if (name === 'settings') {
if (shouldBeVisible) {
// Cancel any pending hide operations
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
settingsHideTimer = null;
}
const position = layoutManager.calculateSettingsWindowPosition();
if (position) {
win.setBounds(position);
win.__lockedByButton = true;
win.show();
win.moveTop();
win.setAlwaysOnTop(true);
} else {
console.warn('[WindowManager] Could not calculate settings window position.');
}
} else {
// Hide after a delay
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
}
settingsHideTimer = setTimeout(() => {
if (win && !win.isDestroyed()) {
win.setAlwaysOnTop(false);
win.hide();
}
settingsHideTimer = null;
}, 200);
win.__lockedByButton = false;
}
return; return;
} }
const otherName = name === 'listen' ? 'ask' : 'listen';
const otherWin = windowPool.get(otherName);
const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible();
const ANIM_OFFSET_X = 100;
const ANIM_OFFSET_Y = 20;
if (shouldBeVisible) {
win.setOpacity(0);
if (name === 'listen') {
if (!isOtherWinVisible) {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false });
if (!targets.listen) return;
const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y };
win.setBounds(startPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(win, targets.listen.x, targets.listen.y);
if (name === 'shortcut-settings') {
if (shouldBeVisible) {
layoutManager.positionShortcutSettingsWindow();
if (process.platform === 'darwin') {
win.setAlwaysOnTop(true, 'screen-saver');
} else { } else {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); win.setAlwaysOnTop(true);
if (!targets.listen || !targets.ask) return;
const startListenPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y };
win.setBounds(startListenPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(otherWin, targets.ask.x, targets.ask.y);
movementManager.animateWindow(win, targets.listen.x, targets.listen.y);
} }
} else if (name === 'ask') { // globalShortcut.unregisterAll();
if (!isOtherWinVisible) { disableClicks(win);
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: false, ask: true }); win.show();
if (!targets.ask) return; } else {
if (process.platform === 'darwin') {
const startPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y }; win.setAlwaysOnTop(false, 'screen-saver');
win.setBounds(startPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(win, targets.ask.x, targets.ask.y);
} else { } else {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true }); win.setAlwaysOnTop(false);
if (!targets.listen || !targets.ask) return;
const startAskPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y };
win.setBounds(startAskPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y);
movementManager.animateWindow(win, targets.ask.x, targets.ask.y);
} }
restoreClicks();
win.hide();
} }
} else { return;
const currentBounds = win.getBounds(); }
fadeWindow(
win, 1, 0, FADE_DURATION,
() => win.hide()
);
if (name === 'listen') {
if (!isOtherWinVisible) {
const targetX = currentBounds.x - ANIM_OFFSET_X;
movementManager.animateWindow(win, targetX, currentBounds.y);
} else {
const targetX = currentBounds.x - currentBounds.width;
movementManager.animateWindow(win, targetX, currentBounds.y);
}
} else if (name === 'ask') {
if (!isOtherWinVisible) {
const targetY = currentBounds.y - ANIM_OFFSET_Y;
movementManager.animateWindow(win, currentBounds.x, targetY);
} else {
const targetAskY = currentBounds.y - ANIM_OFFSET_Y;
movementManager.animateWindow(win, currentBounds.x, targetAskY);
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false }); if (name === 'listen' || name === 'ask') {
if (targets.listen) { const otherName = name === 'listen' ? 'ask' : 'listen';
const otherWin = windowPool.get(otherName);
const isOtherWinVisible = otherWin && !otherWin.isDestroyed() && otherWin.isVisible();
const ANIM_OFFSET_X = 100;
const ANIM_OFFSET_Y = 20;
if (shouldBeVisible) {
win.setOpacity(0);
if (name === 'listen') {
if (!isOtherWinVisible) {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false });
if (!targets.listen) return;
const startPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y };
win.setBounds(startPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(win, targets.listen.x, targets.listen.y);
} else {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true });
if (!targets.listen || !targets.ask) return;
const startListenPos = { x: targets.listen.x - ANIM_OFFSET_X, y: targets.listen.y };
win.setBounds(startListenPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(otherWin, targets.ask.x, targets.ask.y);
movementManager.animateWindow(win, targets.listen.x, targets.listen.y);
}
} else if (name === 'ask') {
if (!isOtherWinVisible) {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: false, ask: true });
if (!targets.ask) return;
const startPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y };
win.setBounds(startPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(win, targets.ask.x, targets.ask.y);
} else {
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: true });
if (!targets.listen || !targets.ask) return;
const startAskPos = { x: targets.ask.x, y: targets.ask.y - ANIM_OFFSET_Y };
win.setBounds(startAskPos);
win.show();
fadeWindow(win, 0, 1);
movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y); movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y);
movementManager.animateWindow(win, targets.ask.x, targets.ask.y);
}
}
} else {
const currentBounds = win.getBounds();
fadeWindow(
win, 1, 0, FADE_DURATION,
() => win.hide()
);
if (name === 'listen') {
if (!isOtherWinVisible) {
const targetX = currentBounds.x - ANIM_OFFSET_X;
movementManager.animateWindow(win, targetX, currentBounds.y);
} else {
const targetX = currentBounds.x - currentBounds.width;
movementManager.animateWindow(win, targetX, currentBounds.y);
}
} else if (name === 'ask') {
if (!isOtherWinVisible) {
const targetY = currentBounds.y - ANIM_OFFSET_Y;
movementManager.animateWindow(win, currentBounds.x, targetY);
} else {
const targetAskY = currentBounds.y - ANIM_OFFSET_Y;
movementManager.animateWindow(win, currentBounds.x, targetAskY);
const targets = layoutManager.getTargetBoundsForFeatureWindows({ listen: true, ask: false });
if (targets.listen) {
movementManager.animateWindow(otherWin, targets.listen.x, targets.listen.y);
}
} }
} }
} }
@ -276,62 +396,6 @@ const resizeHeaderWindow = ({ width, height }) => {
return { success: false, error: 'Header window not found' }; return { success: false, error: 'Header window not found' };
}; };
const openShortcutEditor = () => {
const header = windowPool.get('header');
if (!header) return;
globalShortcut.unregisterAll();
createFeatureWindows(header, 'shortcut-settings');
};
const showSettingsWindow = (bounds) => {
if (!bounds) return;
const win = windowPool.get('settings');
if (win && !win.isDestroyed()) {
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
settingsHideTimer = null;
}
const header = windowPool.get('header');
const headerBounds = header?.getBounds() ?? { x: 0, y: 0 };
const settingsBounds = win.getBounds();
const disp = getCurrentDisplay(header);
const { x: waX, y: waY, width: waW, height: waH } = disp.workArea;
let x = Math.round(headerBounds.x + (bounds?.x ?? 0) + (bounds?.width ?? 0) / 2 - settingsBounds.width / 2);
let y = Math.round(headerBounds.y + (bounds?.y ?? 0) + (bounds?.height ?? 0) + 31);
x = Math.max(waX + 10, Math.min(waX + waW - settingsBounds.width - 10, x));
y = Math.max(waY + 10, Math.min(waY + waH - settingsBounds.height - 10, y));
win.setBounds({ x, y });
win.__lockedByButton = true;
win.show();
win.moveTop();
win.setAlwaysOnTop(true);
}
};
const hideSettingsWindow = () => {
const window = windowPool.get("settings");
if (window && !window.isDestroyed()) {
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
}
settingsHideTimer = setTimeout(() => {
if (window && !window.isDestroyed()) {
window.setAlwaysOnTop(false);
window.hide();
}
settingsHideTimer = null;
}, 200);
window.__lockedByButton = false;
}
};
const cancelHideSettingsWindow = () => {
if (settingsHideTimer) {
clearTimeout(settingsHideTimer);
settingsHideTimer = null;
}
};
const openLoginPage = () => { const openLoginPage = () => {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000'; const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
@ -474,7 +538,7 @@ function createFeatureWindows(header, namesToCreate) {
case 'shortcut-settings': { case 'shortcut-settings': {
const shortcutEditor = new BrowserWindow({ const shortcutEditor = new BrowserWindow({
...commonChildOptions, ...commonChildOptions,
width: 420, width: 353,
height: 720, height: 720,
modal: false, modal: false,
parent: undefined, parent: undefined,
@ -482,37 +546,12 @@ function createFeatureWindows(header, namesToCreate) {
titleBarOverlay: false, titleBarOverlay: false,
}); });
shortcutEditor.setContentProtection(isContentProtectionOn);
shortcutEditor.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') { if (process.platform === 'darwin') {
shortcutEditor.setAlwaysOnTop(true, 'screen-saver'); shortcutEditor.setWindowButtonVisibility(false);
} else {
shortcutEditor.setAlwaysOnTop(true);
} }
/* ──────────[ ① 다른 창 클릭 차단 ]────────── */
const disableClicks = () => {
for (const [name, win] of windowPool) {
if (win !== shortcutEditor && !win.isDestroyed()) {
win.setIgnoreMouseEvents(true, { forward: true });
}
}
};
const restoreClicks = () => {
for (const [, win] of windowPool) {
if (!win.isDestroyed()) win.setIgnoreMouseEvents(false);
}
};
const header = windowPool.get('header');
if (header && !header.isDestroyed()) {
const { x, y, width } = header.getBounds();
shortcutEditor.setBounds({ x, y, width });
}
shortcutEditor.once('ready-to-show', () => {
disableClicks();
shortcutEditor.show();
});
const loadOptions = { query: { view: 'shortcut-settings' } }; const loadOptions = { query: { view: 'shortcut-settings' } };
if (!shouldUseLiquidGlass) { if (!shouldUseLiquidGlass) {
shortcutEditor.loadFile(path.join(__dirname, '../ui/app/content.html'), loadOptions); shortcutEditor.loadFile(path.join(__dirname, '../ui/app/content.html'), loadOptions);
@ -527,22 +566,10 @@ function createFeatureWindows(header, namesToCreate) {
}); });
} }
shortcutEditor.on('closed', () => { windowPool.set('shortcut-settings', shortcutEditor);
restoreClicks();
windowPool.delete('shortcut-settings');
console.log('[Shortcuts] Re-enabled after editing.');
shortcutsService.registerShortcuts();
});
shortcutEditor.webContents.once('dom-ready', async () => {
const keybinds = await shortcutsService.loadKeybinds();
shortcutEditor.webContents.send('load-shortcuts', keybinds);
});
if (!app.isPackaged) { if (!app.isPackaged) {
shortcutEditor.webContents.openDevTools({ mode: 'detach' }); shortcutEditor.webContents.openDevTools({ mode: 'detach' });
} }
windowPool.set('shortcut-settings', shortcutEditor);
break; break;
} }
} }
@ -556,6 +583,7 @@ function createFeatureWindows(header, namesToCreate) {
createFeatureWindow('listen'); createFeatureWindow('listen');
createFeatureWindow('ask'); createFeatureWindow('ask');
createFeatureWindow('settings'); createFeatureWindow('settings');
createFeatureWindow('shortcut-settings');
} }
} }
@ -593,35 +621,7 @@ function getDisplayById(displayId) {
function toggleAllWindowsVisibility() {
const header = windowPool.get('header');
if (!header) return;
if (header.isVisible()) {
lastVisibleWindows.clear();
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed() && win.isVisible()) {
lastVisibleWindows.add(name);
}
});
lastVisibleWindows.forEach(name => {
if (name === 'header') return;
const win = windowPool.get(name);
if (win && !win.isDestroyed()) win.hide();
});
header.hide();
return;
}
lastVisibleWindows.forEach(name => {
const win = windowPool.get(name);
if (win && !win.isDestroyed())
win.show();
});
}
function createWindows() { function createWindows() {
@ -690,7 +690,7 @@ function createWindows() {
}); });
setupIpcHandlers(movementManager); setupIpcHandlers(movementManager);
setupAnimationController(windowPool, layoutManager, movementManager); setupWindowController(windowPool, layoutManager, movementManager);
if (currentHeaderState === 'main') { if (currentHeaderState === 'main') {
createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']); createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']);
@ -850,13 +850,6 @@ const adjustWindowHeight = (sender, targetHeight) => {
}; };
const closeWindow = (windowName) => {
const win = windowPool.get(windowName);
if (win && !win.isDestroyed()) {
win.close();
}
};
module.exports = { module.exports = {
updateLayout, updateLayout,
createWindows, createWindows,
@ -864,14 +857,11 @@ module.exports = {
toggleContentProtection, toggleContentProtection,
resizeHeaderWindow, resizeHeaderWindow,
getContentProtectionStatus, getContentProtectionStatus,
openShortcutEditor,
showSettingsWindow, showSettingsWindow,
hideSettingsWindow, hideSettingsWindow,
cancelHideSettingsWindow, cancelHideSettingsWindow,
openLoginPage, openLoginPage,
moveWindowStep, moveWindowStep,
closeWindow,
toggleAllWindowsVisibility,
handleHeaderStateChanged, handleHeaderStateChanged,
handleHeaderAnimationFinished, handleHeaderAnimationFinished,
getHeaderPosition, getHeaderPosition,