Refactor: Implement local AI service management system

- Add LocalAIServiceManager for centralized local AI service lifecycle management
- Refactor provider settings to support local AI service configuration
- Remove userModelSelections in favor of provider settings integration
- Update whisper service to use new local AI management system
- Implement lazy loading and auto-cleanup for local AI services
- Update UI components to reflect new local AI service architecture
This commit is contained in:
jhyang0 2025-07-15 14:04:34 +09:00
parent c0cf74273a
commit 6ece74737b
22 changed files with 1768 additions and 1063 deletions

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,6 +7,8 @@ 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 windowBridge = require('./windowBridge');
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');
@ -40,6 +42,8 @@ module.exports = {
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions()); ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission()); ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section)); ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section));
//TODO: Need to Remove this
ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted()); ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted()); ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
@ -113,6 +117,115 @@ module.exports = {
ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
// 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,9 +1,13 @@
// 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());

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

@ -74,10 +74,88 @@ async function removeAllByUid(uid) {
} }
} }
// Get active provider for a specific type (llm or stt)
async function getActiveProvider(uid, type) {
try {
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
const q = query(providerSettingsCol(),
where('uid', '==', uid),
where(column, '==', true)
);
const querySnapshot = await getDocs(q);
if (querySnapshot.empty) {
return null;
}
const doc = querySnapshot.docs[0];
return { id: doc.id, ...doc.data() };
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting active provider:', error);
return null;
}
}
// Set active provider for a specific type
async function setActiveProvider(uid, provider, type) {
try {
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
// First, deactivate all providers for this type
const allSettings = await getAllByUid(uid);
const updatePromises = allSettings.map(setting => {
const docRef = doc(providerSettingsCol(), setting.id);
return setDoc(docRef, { [column]: false }, { merge: true });
});
await Promise.all(updatePromises);
// Then activate the specified provider
if (provider) {
const docRef = doc(providerSettingsCol(), `${uid}_${provider}`);
await setDoc(docRef, { [column]: true }, { merge: true });
}
return { success: true };
} catch (error) {
console.error('[ProviderSettings Firebase] Error setting active provider:', error);
throw error;
}
}
// Get all active settings (both llm and stt)
async function getActiveSettings(uid) {
try {
// Firebase doesn't support OR queries in this way, so we'll get all settings and filter
const allSettings = await getAllByUid(uid);
const activeSettings = {
llm: null,
stt: null
};
allSettings.forEach(setting => {
if (setting.is_active_llm) {
activeSettings.llm = setting;
}
if (setting.is_active_stt) {
activeSettings.stt = setting;
}
});
return activeSettings;
} catch (error) {
console.error('[ProviderSettings Firebase] Error getting active settings:', error);
return { llm: null, stt: null };
}
}
module.exports = { module.exports = {
getByProvider, getByProvider,
getAllByUid, getAllByUid,
upsert, upsert,
remove, remove,
removeAllByUid removeAllByUid,
getActiveProvider,
setActiveProvider,
getActiveSettings
}; };

View File

@ -56,6 +56,24 @@ const providerSettingsRepositoryAdapter = {
const repo = getBaseRepository(); const repo = getBaseRepository();
const uid = authService.getCurrentUserId(); const uid = authService.getCurrentUserId();
return await repo.removeAllByUid(uid); return await repo.removeAllByUid(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,15 +1,15 @@
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();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?'); const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
const result = stmt.get(uid, provider) || null; const result = stmt.get(uid, provider) || null;
if (result && result.api_key) { // if (result && result.api_key) {
// Decrypt API key if it exists // // Decrypt API key if it exists
result.api_key = encryptionService.decrypt(result.api_key); // result.api_key = result.api_key;
} // }
return result; return result;
} }
@ -22,40 +22,49 @@ function getAllByUid(uid) {
// Decrypt API keys for all results // Decrypt API keys for all results
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();
// Encrypt API key if it exists // Encrypt API key if it exists
const encryptedSettings = { ...settings }; // const encryptedSettings = { ...settings };
if (encryptedSettings.api_key) { // if (encryptedSettings.api_key) {
encryptedSettings.api_key = encryptionService.encrypt(encryptedSettings.api_key); // encryptedSettings.api_key = encryptedSettings.api_key;
} // }
// 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
`); `);
const result = stmt.run( const result = stmt.run(
uid, uid,
provider, provider,
encryptedSettings.api_key || null, settings.api_key || null,
encryptedSettings.selected_llm_model || null, settings.selected_llm_model || null,
encryptedSettings.selected_stt_model || null, settings.selected_stt_model || null,
encryptedSettings.created_at || Date.now(), 0, // is_active_llm - always 0, use setActiveProvider to activate
encryptedSettings.updated_at 0, // is_active_stt - always 0, use setActiveProvider to activate
settings.created_at || Date.now(),
settings.updated_at
); );
return { changes: result.changes }; return { changes: result.changes };
@ -75,10 +84,79 @@ function removeAllByUid(uid) {
return { changes: result.changes }; return { changes: result.changes };
} }
// 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,
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');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) { if (!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

@ -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');
// Import authService directly (singleton) // Import authService directly (singleton)
const authService = require('./authService'); const authService = require('./authService');
@ -19,25 +17,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;
@ -86,6 +113,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();
@ -115,17 +202,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, {
await userModelSelectionsRepository.upsert({ selected_llm_model: selectedModels.llm
selected_llm_provider: llmProvider, });
selected_llm_model: selectedModels.llm, await providerSettingsRepository.setActiveProvider(llmProvider, 'llm');
selected_stt_provider: sttProvider, console.log(`[ModelStateService] Migrated LLM model selection: ${selectedModels.llm}`);
selected_stt_model: selectedModels.stt }
}); }
console.log('[ModelStateService] Migrated global model selections');
if (selectedModels.stt) {
const sttProvider = this.getProviderForModel('stt', selectedModels.stt);
if (sttProvider) {
await providerSettingsRepository.upsert(sttProvider, {
selected_stt_model: selectedModels.stt
});
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
@ -159,11 +255,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 = {
@ -197,6 +293,9 @@ class ModelStateService extends EventEmitter {
// Initialize encryption service for current user // Initialize encryption service for current user
await encryptionService.initializeKey(userId); await encryptionService.initializeKey(userId);
// 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();
@ -232,17 +331,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}`);
@ -344,8 +464,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) {
@ -363,8 +483,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;
@ -506,12 +626,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;
} }
@ -578,7 +707,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();
@ -71,10 +291,15 @@ class WhisperService extends LocalAIServiceBase {
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;
@ -493,6 +787,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

@ -110,13 +110,17 @@ class ListenService {
console.log('[ListenService] changeSession to "Listen"'); console.log('[ListenService] changeSession to "Listen"');
internalBridge.emit('window:requestVisibility', { 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':

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',
@ -58,17 +57,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

@ -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');
} }
}, },
@ -239,10 +252,11 @@ 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

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

@ -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()
@ -1192,12 +1221,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>
@ -1205,51 +1229,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>
@ -1331,6 +1310,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;
@ -1338,10 +1320,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>
`; `;