diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index 17487cc..eccccbe 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -109,15 +109,15 @@ module.exports = { // ModelStateService ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); - ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys()); + ipcMain.handle('model:get-all-keys', async () => await modelStateService.getAllApiKeys()); ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key)); ipcMain.handle('model:remove-api-key', async (e, provider) => await modelStateService.handleRemoveApiKey(provider)); - ipcMain.handle('model:get-selected-models', () => modelStateService.getSelectedModels()); + ipcMain.handle('model:get-selected-models', async () => await modelStateService.getSelectedModels()); ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId)); - ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type)); - ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); + ipcMain.handle('model:get-available-models', async (e, { type }) => await modelStateService.getAvailableModels(type)); + ipcMain.handle('model:are-providers-configured', async () => await modelStateService.areProvidersConfigured()); ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); - ipcMain.handle('model:re-initialize-state', () => modelStateService.initialize()); + ipcMain.handle('model:re-initialize-state', async () => await modelStateService.initialize()); // LocalAIManager 이벤트를 모든 윈도우에 브로드캐스트 localAIManager.on('install-progress', (service, data) => { diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index 4274583..a7ac4b5 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -243,7 +243,7 @@ class AskService { await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); - const modelInfo = modelStateService.getCurrentModelInfo('llm'); + const modelInfo = await modelStateService.getCurrentModelInfo('llm'); if (!modelInfo || !modelInfo.apiKey) { throw new Error('AI model or API key not configured.'); } diff --git a/src/features/common/config/schema.js b/src/features/common/config/schema.js index 535cc08..15af678 100644 --- a/src/features/common/config/schema.js +++ b/src/features/common/config/schema.js @@ -91,7 +91,6 @@ const LATEST_SCHEMA = { }, provider_settings: { columns: [ - { name: 'uid', type: 'TEXT NOT NULL' }, { name: 'provider', type: 'TEXT NOT NULL' }, { name: 'api_key', type: 'TEXT' }, { name: 'selected_llm_model', type: 'TEXT' }, @@ -101,7 +100,7 @@ const LATEST_SCHEMA = { { name: 'created_at', type: 'INTEGER' }, { name: 'updated_at', type: 'INTEGER' } ], - constraints: ['PRIMARY KEY (uid, provider)'] + constraints: ['PRIMARY KEY (provider)'] }, shortcuts: { columns: [ diff --git a/src/features/common/repositories/firestoreConverter.js b/src/features/common/repositories/firestoreConverter.js index 695f039..14506ab 100644 --- a/src/features/common/repositories/firestoreConverter.js +++ b/src/features/common/repositories/firestoreConverter.js @@ -33,7 +33,13 @@ function createEncryptedConverter(fieldsToEncrypt = []) { for (const field of fieldsToEncrypt) { if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) { - appObject[field] = encryptionService.decrypt(appObject[field]); + try { + appObject[field] = encryptionService.decrypt(appObject[field]); + } catch (error) { + console.warn(`[FirestoreConverter] Failed to decrypt field '${field}' (possibly plaintext or key mismatch):`, error.message); + // Keep the original value instead of failing + // appObject[field] remains as is + } } } diff --git a/src/features/common/repositories/providerSettings/index.js b/src/features/common/repositories/providerSettings/index.js index c5ad4a2..79edffd 100644 --- a/src/features/common/repositories/providerSettings/index.js +++ b/src/features/common/repositories/providerSettings/index.js @@ -1,12 +1,7 @@ const sqliteRepository = require('./sqlite.repository'); -let authService = null; - -function setAuthService(service) { - authService = service; -} - function getBaseRepository() { + // For now, we only have sqlite. This could be expanded later. return sqliteRepository; } @@ -14,71 +9,60 @@ const providerSettingsRepositoryAdapter = { // Core CRUD operations async getByProvider(provider) { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.getByProvider(uid, provider); + return await repo.getByProvider(provider); }, - async getAllByUid() { + async getAll() { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.getAllByUid(uid); + return await repo.getAll(); }, async upsert(provider, settings) { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); const now = Date.now(); const settingsWithMeta = { ...settings, - uid, provider, updated_at: now, created_at: settings.created_at || now }; - return await repo.upsert(uid, provider, settingsWithMeta); + return await repo.upsert(provider, settingsWithMeta); }, async remove(provider) { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.remove(uid, provider); + return await repo.remove(provider); }, - async removeAllByUid() { + async removeAll() { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.removeAllByUid(uid); + return await repo.removeAll(); }, - async getRawApiKeysByUid() { + async getRawApiKeys() { // This function should always target the local sqlite DB, // as it's part of the local-first boot sequence. - const uid = authService.getCurrentUserId(); - return await sqliteRepository.getRawApiKeysByUid(uid); + return await sqliteRepository.getRawApiKeys(); }, async getActiveProvider(type) { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.getActiveProvider(uid, type); + return await repo.getActiveProvider(type); }, async setActiveProvider(provider, type) { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.setActiveProvider(uid, provider, type); + return await repo.setActiveProvider(provider, type); }, async getActiveSettings() { const repo = getBaseRepository(); - const uid = authService.getCurrentUserId(); - return await repo.getActiveSettings(uid); + return await repo.getActiveSettings(); } }; module.exports = { - ...providerSettingsRepositoryAdapter, - setAuthService + ...providerSettingsRepositoryAdapter }; \ No newline at end of file diff --git a/src/features/common/repositories/providerSettings/sqlite.repository.js b/src/features/common/repositories/providerSettings/sqlite.repository.js index 3b90e7a..2e68d2a 100644 --- a/src/features/common/repositories/providerSettings/sqlite.repository.js +++ b/src/features/common/repositories/providerSettings/sqlite.repository.js @@ -1,31 +1,32 @@ const sqliteClient = require('../../services/sqliteClient'); +const encryptionService = require('../../services/encryptionService'); -function getByProvider(uid, provider) { +function getByProvider(provider) { const db = sqliteClient.getDb(); - const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?'); - const result = stmt.get(uid, provider) || null; + const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?'); + const result = stmt.get(provider) || null; - if (result && result.api_key) { + if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) { result.api_key = encryptionService.decrypt(result.api_key); } return result; } -function getAllByUid(uid) { +function getAll() { const db = sqliteClient.getDb(); - const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider'); - const results = stmt.all(uid); + const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider'); + const results = stmt.all(); return results.map(result => { - if (result.api_key) { - result.api_key = result.api_key; + if (result.api_key && encryptionService.looksEncrypted(result.api_key)) { + result.api_key = encryptionService.decrypt(result.api_key); } return result; }); } -function upsert(uid, provider, settings) { +function upsert(provider, settings) { // Validate: prevent direct setting of active status if (settings.is_active_llm || settings.is_active_stt) { console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.'); @@ -35,9 +36,9 @@ function upsert(uid, provider, settings) { // Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE) const stmt = db.prepare(` - INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(uid, provider) DO UPDATE SET + INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(provider) DO UPDATE SET api_key = excluded.api_key, selected_llm_model = excluded.selected_llm_model, selected_stt_model = excluded.selected_stt_model, @@ -47,7 +48,6 @@ function upsert(uid, provider, settings) { `); const result = stmt.run( - uid, provider, settings.api_key || null, settings.selected_llm_model || null, @@ -61,55 +61,55 @@ function upsert(uid, provider, settings) { return { changes: result.changes }; } -function remove(uid, provider) { +function remove(provider) { const db = sqliteClient.getDb(); - const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ? AND provider = ?'); - const result = stmt.run(uid, provider); + const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?'); + const result = stmt.run(provider); return { changes: result.changes }; } -function removeAllByUid(uid) { +function removeAll() { const db = sqliteClient.getDb(); - const stmt = db.prepare('DELETE FROM provider_settings WHERE uid = ?'); - const result = stmt.run(uid); + const stmt = db.prepare('DELETE FROM provider_settings'); + const result = stmt.run(); return { changes: result.changes }; } -function getRawApiKeysByUid(uid) { +function getRawApiKeys() { const db = sqliteClient.getDb(); - const stmt = db.prepare('SELECT api_key FROM provider_settings WHERE uid = ?'); - return stmt.all(uid); + const stmt = db.prepare('SELECT api_key FROM provider_settings'); + return stmt.all(); } // Get active provider for a specific type (llm or stt) -function getActiveProvider(uid, type) { +function getActiveProvider(type) { const db = sqliteClient.getDb(); const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; - const stmt = db.prepare(`SELECT * FROM provider_settings WHERE uid = ? AND ${column} = 1`); - const result = stmt.get(uid) || null; + const stmt = db.prepare(`SELECT * FROM provider_settings WHERE ${column} = 1`); + const result = stmt.get() || null; - if (result && result.api_key) { - result.api_key = result.api_key; + if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) { + result.api_key = encryptionService.decrypt(result.api_key); } return result; } // Set active provider for a specific type -function setActiveProvider(uid, provider, type) { +function setActiveProvider(provider, type) { const db = sqliteClient.getDb(); const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt'; // Start transaction to ensure only one provider is active db.transaction(() => { // First, deactivate all providers for this type - const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0 WHERE uid = ?`); - deactivateStmt.run(uid); + const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0`); + deactivateStmt.run(); // Then activate the specified provider if (provider) { - const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE uid = ? AND provider = ?`); - activateStmt.run(uid, provider); + const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE provider = ?`); + activateStmt.run(provider); } })(); @@ -117,14 +117,14 @@ function setActiveProvider(uid, provider, type) { } // Get all active settings (both llm and stt) -function getActiveSettings(uid) { +function getActiveSettings() { const db = sqliteClient.getDb(); const stmt = db.prepare(` SELECT * FROM provider_settings - WHERE uid = ? AND (is_active_llm = 1 OR is_active_stt = 1) + WHERE (is_active_llm = 1 OR is_active_stt = 1) ORDER BY provider `); - const results = stmt.all(uid); + const results = stmt.all(); // Decrypt API keys and organize by type const activeSettings = { @@ -133,8 +133,8 @@ function getActiveSettings(uid) { }; results.forEach(result => { - if (result.api_key) { - result.api_key = result.api_key; + if (result.api_key && encryptionService.looksEncrypted(result.api_key)) { + result.api_key = encryptionService.decrypt(result.api_key); } if (result.is_active_llm) { activeSettings.llm = result; @@ -149,11 +149,11 @@ function getActiveSettings(uid) { module.exports = { getByProvider, - getAllByUid, + getAll, upsert, remove, - removeAllByUid, - getRawApiKeysByUid, + removeAll, + getRawApiKeys, getActiveProvider, setActiveProvider, getActiveSettings diff --git a/src/features/common/services/authService.js b/src/features/common/services/authService.js index fe8f3c2..15fb74d 100644 --- a/src/features/common/services/authService.js +++ b/src/features/common/services/authService.js @@ -46,7 +46,6 @@ class AuthService { this.initializationPromise = null; sessionRepository.setAuthService(this); - providerSettingsRepository.setAuthService(this); } initialize() { @@ -78,22 +77,21 @@ class AuthService { // No 'await' here, so it runs in the background without blocking startup. migrationService.checkAndRunMigration(user); + // ***** CRITICAL: Wait for the virtual key and model state update to complete ***** + try { + const idToken = await user.getIdToken(true); + const virtualKey = await getVirtualKeyByEmail(user.email, idToken); - // Start background task to fetch and save virtual key - (async () => { - try { - const idToken = await user.getIdToken(true); - const virtualKey = await getVirtualKeyByEmail(user.email, idToken); - - if (global.modelStateService) { - global.modelStateService.setFirebaseVirtualKey(virtualKey); - } - console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`); - - } catch (error) { - console.error('[AuthService] BG: Failed to fetch or save virtual key:', error); + if (global.modelStateService) { + // The model state service now writes directly to the DB, no in-memory state. + await global.modelStateService.setFirebaseVirtualKey(virtualKey); } - })(); + console.log(`[AuthService] Virtual key for ${user.email} has been processed and state updated.`); + + } catch (error) { + console.error('[AuthService] Failed to fetch or save virtual key:', error); + // This is not critical enough to halt the login, but we should log it. + } } else { // User signed OUT @@ -101,7 +99,8 @@ class AuthService { if (previousUser) { console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`); if (global.modelStateService) { - global.modelStateService.setFirebaseVirtualKey(null); + // The model state service now writes directly to the DB. + await global.modelStateService.setFirebaseVirtualKey(null); } } this.currentUser = null; diff --git a/src/features/common/services/modelStateService.js b/src/features/common/services/modelStateService.js index 6d2f296..e81ec31 100644 --- a/src/features/common/services/modelStateService.js +++ b/src/features/common/services/modelStateService.js @@ -1,758 +1,437 @@ -const Store = require('electron-store'); -const fetch = require('node-fetch'); const { EventEmitter } = require('events'); +const Store = require('electron-store'); const { PROVIDERS, getProviderClass } = require('../ai/factory'); const encryptionService = require('./encryptionService'); const providerSettingsRepository = require('../repositories/providerSettings'); const authService = require('./authService'); +const ollamaModelRepository = require('../repositories/ollamaModel'); class ModelStateService extends EventEmitter { constructor() { super(); this.authService = authService; + // electron-store는 오직 레거시 데이터 마이그레이션 용도로만 사용됩니다. this.store = new Store({ name: 'pickle-glass-model-state' }); - - const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, provider) => { - acc[provider] = null; - return acc; - }, {}); - - this.state = { - apiKeys: initialApiKeys, - selectedModels: { - llm: null, - stt: null - } - }; - this.hasMigrated = false; } - async initialize() { - console.log('[ModelStateService] Initializing...'); - await this._loadStateForCurrentUser(); - - // LocalAI 상태 변경 이벤트 구독 + console.log('[ModelStateService] Initializing one-time setup...'); + await this._initializeEncryption(); + await this._runMigrations(); this.setupLocalAIStateSync(); - - console.log('[ModelStateService] Initialization complete'); + await this._autoSelectAvailableModels([], true); + console.log('[ModelStateService] One-time setup 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() { - const llmModel = this.state.selectedModels.llm; - const sttModel = this.state.selectedModels.stt; - const llmProvider = this.getProviderForModel('llm', llmModel) || 'None'; - const sttProvider = this.getProviderForModel('stt', sttModel) || 'None'; - - console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`); - } - - _autoSelectAvailableModels(forceReselectionForTypes = []) { - console.log(`[ModelStateService] Running auto-selection for models. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`); - const types = ['llm', 'stt']; - - types.forEach(type => { - const currentModelId = this.state.selectedModels[type]; - let isCurrentModelValid = false; - - const forceReselection = forceReselectionForTypes.includes(type); - - if (currentModelId && !forceReselection) { - const provider = this.getProviderForModel(type, currentModelId); - const apiKey = this.getApiKey(provider); - // For Ollama, 'local' is a valid API key - if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) { - isCurrentModelValid = true; - } - } - - if (!isCurrentModelValid) { - console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or re-selection forced. Finding an alternative...`); - const availableModels = this.getAvailableModels(type); - if (availableModels.length > 0) { - // Prefer API providers over local providers for auto-selection - const apiModel = availableModels.find(model => { - const provider = this.getProviderForModel(type, model.id); - return provider && provider !== 'ollama' && provider !== 'whisper'; - }); - - const selectedModel = apiModel || availableModels[0]; - this.state.selectedModels[type] = selectedModel.id; - console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${selectedModel.id} (preferred: ${apiModel ? 'API' : 'local'})`); - } else { - this.state.selectedModels[type] = null; - } - } - }); - } - - async _migrateUserModelSelections() { - console.log('[ModelStateService] Checking for user_model_selections migration...'); - const userId = this.authService.getCurrentUserId(); - + async _initializeEncryption() { 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() { - console.log('[ModelStateService] Starting migration from electron-store to database...'); - const userId = this.authService.getCurrentUserId(); - - try { - // Get data from electron-store - const legacyData = this.store.get(`users.${userId}`, null); - - if (!legacyData) { - console.log('[ModelStateService] No legacy data to migrate'); - return; - } - - console.log('[ModelStateService] Found legacy data, migrating...'); - - // Migrate provider settings (API keys and selected models per provider) - const { apiKeys = {}, selectedModels = {} } = legacyData; - - for (const [provider, apiKey] of Object.entries(apiKeys)) { - if (apiKey && PROVIDERS[provider]) { - // For encrypted keys, they are already decrypted in _loadStateForCurrentUser - await providerSettingsRepository.upsert(provider, { - api_key: apiKey - }); - console.log(`[ModelStateService] Migrated API key for ${provider}`); - } - } - - // Migrate global model selections - if (selectedModels.llm) { - const llmProvider = this.getProviderForModel('llm', selectedModels.llm); - if (llmProvider) { - await providerSettingsRepository.upsert(llmProvider, { - selected_llm_model: selectedModels.llm - }); - await providerSettingsRepository.setActiveProvider(llmProvider, 'llm'); - console.log(`[ModelStateService] Migrated LLM model selection: ${selectedModels.llm}`); - } - } - - 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 - this.store.delete(`users.${userId}`); - console.log('[ModelStateService] Migration completed and legacy data cleaned up'); - - } catch (error) { - console.error('[ModelStateService] Migration failed:', error); - // Don't throw - continue with normal operation - } - } - - async _loadStateFromDatabase() { - console.log('[ModelStateService] Loading state from database...'); - const userId = this.authService.getCurrentUserId(); - - try { - // Load provider settings - const providerSettings = await providerSettingsRepository.getAllByUid(); - const apiKeys = {}; - - // Reconstruct apiKeys object - Object.keys(PROVIDERS).forEach(provider => { - apiKeys[provider] = null; - }); - - for (const setting of providerSettings) { - if (setting.api_key) { - // API keys are already decrypted by the repository layer - apiKeys[setting.provider] = setting.api_key; - } - } - - // Load active model selections from provider settings - const activeSettings = await providerSettingsRepository.getActiveSettings(); - const selectedModels = { - llm: activeSettings.llm?.selected_llm_model || null, - stt: activeSettings.stt?.selected_stt_model || null - }; - - this.state = { - apiKeys, - selectedModels - }; - - console.log(`[ModelStateService] State loaded from database for user: ${userId}`); - - // Auto-select available models after loading state - this._autoSelectAvailableModels(); - - } catch (error) { - console.error('[ModelStateService] Failed to load state from database:', error); - // Fall back to default state - const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => { - acc[key] = null; - return acc; - }, {}); - - this.state = { - apiKeys: initialApiKeys, - selectedModels: { llm: null, stt: null }, - }; - } - } - - async _loadStateForCurrentUser() { - const userId = this.authService.getCurrentUserId(); - - // Conditionally initialize encryption if old encrypted keys are detected - try { - const rows = await providerSettingsRepository.getRawApiKeysByUid(); - if (rows.some(r => encryptionService.looksEncrypted(r.api_key))) { + const rows = await providerSettingsRepository.getRawApiKeys(); + if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) { console.log('[ModelStateService] Encrypted keys detected, initializing encryption...'); - await encryptionService.initializeKey(userId); + const userIdForMigration = this.authService.getCurrentUserId(); + await encryptionService.initializeKey(userIdForMigration); } else { console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.'); } } catch (err) { console.warn('[ModelStateService] Error while checking encrypted keys:', err.message); } - - // Check for user_model_selections migration first - await this._migrateUserModelSelections(); - - // Try to load from database first - await this._loadStateFromDatabase(); - - // Check if we need to migrate from electron-store - const legacyData = this.store.get(`users.${userId}`, null); - if (legacyData && !this.hasMigrated) { - await this._migrateFromElectronStore(); - // Reload state after migration - await this._loadStateFromDatabase(); - this.hasMigrated = true; - } - - this._autoSelectAvailableModels(); - await this._saveState(); - this._logCurrentSelection(); } - async _saveState() { - console.log('[ModelStateService] Saving state to database...'); + async _runMigrations() { + console.log('[ModelStateService] Checking for data migrations...'); const userId = this.authService.getCurrentUserId(); try { - // Save provider settings (API keys) - for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) { - if (apiKey) { - // API keys will be encrypted by the repository layer - await providerSettingsRepository.upsert(provider, { - api_key: apiKey - }); - } else { - // Remove empty API keys - await providerSettingsRepository.remove(provider); + const sqliteClient = require('./sqliteClient'); + const db = sqliteClient.getDb(); + const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_model_selections'").get(); + + if (tableExists) { + const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId); + if (selections) { + console.log('[ModelStateService] Migrating from user_model_selections table...'); + if (selections.llm_model) { + const llmProvider = this.getProviderForModel(selections.llm_model, 'llm'); + if (llmProvider) { + await this.setSelectedModel('llm', selections.llm_model); + } + } + if (selections.stt_model) { + const sttProvider = this.getProviderForModel(selections.stt_model, 'stt'); + if (sttProvider) { + await this.setSelectedModel('stt', selections.stt_model); + } + } + db.prepare('DROP TABLE user_model_selections').run(); + console.log('[ModelStateService] user_model_selections migration complete.'); } } - - // Save model selections and update active providers - const llmModel = this.state.selectedModels.llm; - const sttModel = this.state.selectedModels.stt; - - if (llmModel) { - const llmProvider = this.getProviderForModel('llm', llmModel); - if (llmProvider) { - // Update the provider's selected model - await providerSettingsRepository.upsert(llmProvider, { - 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}`); - this._logCurrentSelection(); - } catch (error) { - console.error('[ModelStateService] Failed to save state to database:', error); - // Fall back to electron-store for now - this._saveStateToElectronStore(); + console.error('[ModelStateService] user_model_selections migration failed:', error); + } + + try { + const legacyData = this.store.get(`users.${userId}`); + if (legacyData && legacyData.apiKeys) { + console.log('[ModelStateService] Migrating from electron-store...'); + for (const [provider, apiKey] of Object.entries(legacyData.apiKeys)) { + if (apiKey && PROVIDERS[provider]) { + await this.setApiKey(provider, apiKey); + } + } + if (legacyData.selectedModels?.llm) { + await this.setSelectedModel('llm', legacyData.selectedModels.llm); + } + if (legacyData.selectedModels?.stt) { + await this.setSelectedModel('stt', legacyData.selectedModels.stt); + } + this.store.delete(`users.${userId}`); + console.log('[ModelStateService] electron-store migration complete.'); + } + } catch (error) { + console.error('[ModelStateService] electron-store migration failed:', error); } } + + setupLocalAIStateSync() { + const localAIManager = require('./localAIManager'); + localAIManager.on('state-changed', (service, status) => { + this.handleLocalAIStateChange(service, status); + }); + } - _saveStateToElectronStore() { - console.log('[ModelStateService] Falling back to electron-store...'); - const userId = this.authService.getCurrentUserId(); - const stateToSave = { - ...this.state, - apiKeys: { ...this.state.apiKeys } + async handleLocalAIStateChange(service, state) { + console.log(`[ModelStateService] LocalAI state changed: ${service}`, state); + if (!state.installed || !state.running) { + const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : []; + await this._autoSelectAvailableModels(types); + } + this.emit('state-updated', await this.getLiveState()); + } + + async getLiveState() { + const providerSettings = await providerSettingsRepository.getAll(); + const apiKeys = {}; + Object.keys(PROVIDERS).forEach(provider => { + const setting = providerSettings.find(s => s.provider === provider); + apiKeys[provider] = setting?.api_key || null; + }); + + const activeSettings = await providerSettingsRepository.getActiveSettings(); + const selectedModels = { + llm: activeSettings.llm?.selected_llm_model || null, + stt: activeSettings.stt?.selected_stt_model || null }; - this.store.set(`users.${userId}`, stateToSave); - console.log(`[ModelStateService] State saved to electron-store for user: ${userId}`); - this._logCurrentSelection(); + return { apiKeys, selectedModels }; } - async validateApiKey(provider, key) { - if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) { - return { success: false, error: 'API key cannot be empty.' }; - } + async _autoSelectAvailableModels(forceReselectionForTypes = [], isInitialBoot = false) { + console.log(`[ModelStateService] Running auto-selection. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`); + const { apiKeys, selectedModels } = await this.getLiveState(); + const types = ['llm', 'stt']; - const ProviderClass = getProviderClass(provider); + for (const type of types) { + const currentModelId = selectedModels[type]; + let isCurrentModelValid = false; + const forceReselection = forceReselectionForTypes.includes(type); - if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') { - // Default to success if no specific validator is found - console.warn(`[ModelStateService] No validateApiKey function for provider: ${provider}. Assuming valid.`); - return { success: true }; - } - - try { - const result = await ProviderClass.validateApiKey(key); - if (result.success) { - console.log(`[ModelStateService] API key for ${provider} is valid.`); - } else { - console.log(`[ModelStateService] API key for ${provider} is invalid: ${result.error}`); + if (currentModelId && !forceReselection) { + const provider = this.getProviderForModel(currentModelId, type); + const apiKey = apiKeys[provider]; + if (provider && apiKey) { + isCurrentModelValid = true; + } + } + + if (!isCurrentModelValid) { + console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`); + const availableModels = await this.getAvailableModels(type); + if (availableModels.length > 0) { + const apiModel = availableModels.find(model => { + const provider = this.getProviderForModel(model.id, type); + return provider && provider !== 'ollama' && provider !== 'whisper'; + }); + const newModel = apiModel || availableModels[0]; + await this.setSelectedModel(type, newModel.id); + console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`); + } else { + await providerSettingsRepository.setActiveProvider(null, type); + if (!isInitialBoot) { + this.emit('state-updated', await this.getLiveState()); + } + } } - return result; - } catch (error) { - console.error(`[ModelStateService] Error during ${provider} key validation:`, error); - return { success: false, error: 'An unexpected error occurred during validation.' }; } } async setFirebaseVirtualKey(virtualKey) { - console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`); - this.state.apiKeys['openai-glass'] = virtualKey; - - const llmModels = PROVIDERS['openai-glass']?.llmModels; - const sttModels = PROVIDERS['openai-glass']?.sttModels; + console.log(`[ModelStateService] Setting Firebase virtual key.`); - // When logging in with Pickle, prioritize Pickle's models over existing selections - if (virtualKey && llmModels?.length > 0) { - this.state.selectedModels.llm = llmModels[0].id; - console.log(`[ModelStateService] Prioritized Pickle LLM model: ${llmModels[0].id}`); + // 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다. + const previousSettings = await providerSettingsRepository.getByProvider('openai-glass'); + const wasPreviouslyConfigured = !!previousSettings?.api_key; + + // 항상 새로운 가상 키로 업데이트합니다. + await this.setApiKey('openai-glass', virtualKey); + + if (virtualKey) { + // 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다. + if (!wasPreviouslyConfigured) { + console.log('[ModelStateService] First-time setup for openai-glass, setting default models.'); + const llmModel = PROVIDERS['openai-glass']?.llmModels[0]; + const sttModel = PROVIDERS['openai-glass']?.sttModels[0]; + if (llmModel) await this.setSelectedModel('llm', llmModel.id); + if (sttModel) await this.setSelectedModel('stt', sttModel.id); + } else { + console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.'); + } + } else { + // 로그아웃 시, 현재 활성화된 모델이 openai-glass인 경우에만 다른 모델로 전환합니다. + const selected = await this.getSelectedModels(); + const llmProvider = this.getProviderForModel(selected.llm, 'llm'); + const sttProvider = this.getProviderForModel(selected.stt, 'stt'); + + const typesToReselect = []; + if (llmProvider === 'openai-glass') typesToReselect.push('llm'); + if (sttProvider === 'openai-glass') typesToReselect.push('stt'); + + if (typesToReselect.length > 0) { + console.log('[ModelStateService] Logged out, re-selecting models for:', typesToReselect.join(', ')); + await this._autoSelectAvailableModels(typesToReselect); + } } - if (virtualKey && sttModels?.length > 0) { - this.state.selectedModels.stt = sttModels[0].id; - console.log(`[ModelStateService] Prioritized Pickle STT model: ${sttModels[0].id}`); - } - - // If logging out (virtualKey is null), run auto-selection to find alternatives - if (!virtualKey) { - this._autoSelectAvailableModels(); - } - - await this._saveState(); - this._logCurrentSelection(); - - // Emit events to update UI - this.emit('state-updated', this.state); - this.emit('settings-updated'); } async setApiKey(provider, key) { - console.log(`[ModelStateService] setApiKey: ${provider}`); + console.log(`[ModelStateService] setApiKey for ${provider}`); if (!provider) { throw new Error('Provider is required'); } + + // 'openai-glass'는 자체 인증 키를 사용하므로 유효성 검사를 건너뜁니다. + if (provider !== 'openai-glass') { + const validationResult = await this.validateApiKey(provider, key); + if (!validationResult.success) { + console.warn(`[ModelStateService] API key validation failed for ${provider}: ${validationResult.error}`); + return validationResult; + } + } + + const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key; + const existingSettings = await providerSettingsRepository.getByProvider(provider) || {}; + await providerSettingsRepository.upsert(provider, { ...existingSettings, api_key: finalKey }); - // API keys will be encrypted by the repository layer - this.state.apiKeys[provider] = key; - await this._saveState(); + // 키가 추가/변경되었으므로, 해당 provider의 모델을 자동 선택할 수 있는지 확인 + await this._autoSelectAvailableModels([]); - this._autoSelectAvailableModels([]); - - this.emit('state-updated', this.state); + this.emit('state-updated', await this.getLiveState()); this.emit('settings-updated'); + return { success: true }; } - getApiKey(provider) { - return this.state.apiKeys[provider]; - } - - getAllApiKeys() { - const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys; - return displayKeys; + async getAllApiKeys() { + const allSettings = await providerSettingsRepository.getAll(); + const apiKeys = {}; + allSettings.forEach(s => { + if (s.provider !== 'openai-glass') { + apiKeys[s.provider] = s.api_key; + } + }); + return apiKeys; } async removeApiKey(provider) { - if (this.state.apiKeys[provider]) { - this.state.apiKeys[provider] = null; - await providerSettingsRepository.remove(provider); - await this._saveState(); - this._autoSelectAvailableModels([]); - this.emit('state-updated', this.state); + const setting = await providerSettingsRepository.getByProvider(provider); + if (setting && setting.api_key) { + await providerSettingsRepository.upsert(provider, { ...setting, api_key: null }); + await this._autoSelectAvailableModels(['llm', 'stt']); + this.emit('state-updated', await this.getLiveState()); this.emit('settings-updated'); return true; } return false; } - getProviderForModel(type, modelId) { - if (!modelId) return null; - for (const providerId in PROVIDERS) { - const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels; - if (models.some(m => m.id === modelId)) { - return providerId; - } - } - - // If no provider was found, assume it could be a custom Ollama model - // if Ollama provider is configured (has a key). - if (type === 'llm' && this.state.apiKeys['ollama']) { - console.log(`[ModelStateService] Model '${modelId}' not found in PROVIDERS list, assuming it's a custom Ollama model.`); - return 'ollama'; - } - - return null; - } - - getCurrentProvider(type) { - const selectedModel = this.state.selectedModels[type]; - return this.getProviderForModel(type, selectedModel); - } - + /** + * 사용자가 Firebase에 로그인했는지 확인합니다. + */ isLoggedInWithFirebase() { return this.authService.getCurrentUser().isLoggedIn; } - areProvidersConfigured() { + /** + * 유효한 API 키가 하나라도 설정되어 있는지 확인합니다. + */ + async hasValidApiKey() { if (this.isLoggedInWithFirebase()) return true; - console.log('[DEBUG] Checking configured providers with apiKeys state:', JSON.stringify(this.state.apiKeys, (key, value) => (value ? '***' : null), 2)); - - // LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인 - const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => { - if (provider === 'ollama') { - // Ollama uses dynamic models, so just check if configured (has 'local' key) - return key === 'local'; - } - if (provider === 'whisper') { - // Whisper doesn't support LLM - return false; - } - return key && PROVIDERS[provider]?.llmModels.length > 0; - }); - - const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => { - if (provider === 'whisper') { - // Whisper has static model list and supports STT - return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0; - } - if (provider === 'ollama') { - // Ollama doesn't support STT yet - return false; - } - return key && PROVIDERS[provider]?.sttModels.length > 0; - }); - - const result = hasLlmKey && hasSttKey; - console.log(`[ModelStateService] areProvidersConfigured: LLM=${hasLlmKey}, STT=${hasSttKey}, result=${result}`); - return result; + const allSettings = await providerSettingsRepository.getAll(); + return allSettings.some(s => s.api_key && s.api_key.trim().length > 0); } - hasValidApiKey() { - if (this.isLoggedInWithFirebase()) return true; - - // Check if any provider has a valid API key - return Object.entries(this.state.apiKeys).some(([provider, key]) => { - if (provider === 'ollama' || provider === 'whisper') { - return key === 'local'; - } - return key && key.trim().length > 0; - }); - } - - - getAvailableModels(type) { - const available = []; - const modelList = type === 'llm' ? 'llmModels' : 'sttModels'; - - for (const [providerId, key] of Object.entries(this.state.apiKeys)) { - if (!key) continue; - - // Ollama의 경우 데이터베이스에서 설치된 모델을 가져오기 - if (providerId === 'ollama' && type === 'llm') { - try { - const ollamaModelRepository = require('../repositories/ollamaModel'); - const installedModels = ollamaModelRepository.getInstalledModels(); - const ollamaModels = installedModels.map(model => ({ - id: model.name, - name: model.name - })); - available.push(...ollamaModels); - } catch (error) { - console.warn('[ModelStateService] Failed to get Ollama models from DB:', error.message); - } - } - // Whisper의 경우 정적 모델 목록 사용 (설치 상태는 별도 확인) - else if (providerId === 'whisper' && type === 'stt') { - // Whisper 모델은 factory.js의 정적 목록 사용 - if (PROVIDERS[providerId]?.[modelList]) { - available.push(...PROVIDERS[providerId][modelList]); - } - } - // 다른 provider들은 기존 로직 사용 - else if (PROVIDERS[providerId]?.[modelList]) { - available.push(...PROVIDERS[providerId][modelList]); + getProviderForModel(arg1, arg2) { + // Compatibility: support both (type, modelId) old order and (modelId, type) new order + let type, modelId; + if (arg1 === 'llm' || arg1 === 'stt') { + type = arg1; + modelId = arg2; + } else { + modelId = arg1; + type = arg2; + } + if (!modelId || !type) return null; + for (const providerId in PROVIDERS) { + const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels; + if (models && models.some(m => m.id === modelId)) { + return providerId; } } - - return [...new Map(available.map(item => [item.id, item])).values()]; + if (type === 'llm') { + const installedModels = ollamaModelRepository.getInstalledModels(); + if (installedModels.some(m => m.name === modelId)) return 'ollama'; + } + return null; + } + + async getSelectedModels() { + const active = await providerSettingsRepository.getActiveSettings(); + return { + llm: active.llm?.selected_llm_model || null, + stt: active.stt?.selected_stt_model || null, + }; } - getSelectedModels() { - return this.state.selectedModels; - } - - setSelectedModel(type, modelId) { - const availableModels = this.getAvailableModels(type); - const isAvailable = availableModels.some(model => model.id === modelId); - - if (!isAvailable) { - console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`); + async setSelectedModel(type, modelId) { + const provider = this.getProviderForModel(modelId, type); + if (!provider) { + console.warn(`[ModelStateService] No provider found for model ${modelId}`); return false; } - - const previousModelId = this.state.selectedModels[type]; - this.state.selectedModels[type] = modelId; - this._saveState(); - - console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`); - - // Auto warm-up for Ollama models - if (type === 'llm' && modelId && modelId !== previousModelId) { - const provider = this.getProviderForModel('llm', modelId); - if (provider === 'ollama') { - 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); - } - } + + const existingSettings = await providerSettingsRepository.getByProvider(provider) || {}; + const newSettings = { ...existingSettings }; + + if (type === 'llm') { + newSettings.selected_llm_model = modelId; + } else { + newSettings.selected_stt_model = modelId; } - this.emit('state-updated', this.state); + await providerSettingsRepository.upsert(provider, newSettings); + await providerSettingsRepository.setActiveProvider(provider, type); + + console.log(`[ModelStateService] Selected ${type} model: ${modelId} (provider: ${provider})`); + + if (type === 'llm' && provider === 'ollama') { + require('./localAIManager').warmUpModel(modelId).catch(err => console.warn(err)); + } + + this.emit('state-updated', await this.getLiveState()); this.emit('settings-updated'); return true; } - /** - * Auto warm-up Ollama model when LLM selection changes - * @private - * @param {string} newModelId - The newly selected model - * @param {string} previousModelId - The previously selected model - */ - async _autoWarmUpOllamaModel(newModelId, previousModelId) { - try { - console.log(`[ModelStateService] LLM model changed: ${previousModelId || 'None'} → ${newModelId}, triggering warm-up`); - - // Get Ollama service if available - const ollamaService = require('./ollamaService'); - if (!ollamaService) { - console.log('[ModelStateService] OllamaService not available for auto warm-up'); - return; - } + async getAvailableModels(type) { + const allSettings = await providerSettingsRepository.getAll(); + const available = []; + const modelListKey = type === 'llm' ? 'llmModels' : 'sttModels'; - // Delay warm-up slightly to allow UI to update first - setTimeout(async () => { - try { - console.log(`[ModelStateService] Starting background warm-up for: ${newModelId}`); - const success = await ollamaService.warmUpModel(newModelId); - - if (success) { - console.log(`[ModelStateService] Successfully warmed up model: ${newModelId}`); - } else { - console.log(`[ModelStateService] Failed to warm up model: ${newModelId}`); - } - } catch (error) { - console.log(`[ModelStateService] Error during auto warm-up for ${newModelId}:`, error.message); - } - }, 500); // 500ms delay - + for (const setting of allSettings) { + if (!setting.api_key) continue; + + const providerId = setting.provider; + if (providerId === 'ollama' && type === 'llm') { + const installed = ollamaModelRepository.getInstalledModels(); + available.push(...installed.map(m => ({ id: m.name, name: m.name }))); + } else if (PROVIDERS[providerId]?.[modelListKey]) { + available.push(...PROVIDERS[providerId][modelListKey]); + } + } + return [...new Map(available.map(item => [item.id, item])).values()]; + } + + async getCurrentModelInfo(type) { + const activeSetting = await providerSettingsRepository.getActiveProvider(type); + if (!activeSetting) return null; + + const model = type === 'llm' ? activeSetting.selected_llm_model : activeSetting.selected_stt_model; + if (!model) return null; + + return { + provider: activeSetting.provider, + model: model, + apiKey: activeSetting.api_key, + }; + } + + // --- 핸들러 및 유틸리티 메서드 --- + + async validateApiKey(provider, key) { + if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) { + return { success: false, error: 'API key cannot be empty.' }; + } + const ProviderClass = getProviderClass(provider); + if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') { + return { success: true }; + } + try { + return await ProviderClass.validateApiKey(key); } catch (error) { - console.error('[ModelStateService] Error in auto warm-up setup:', error); + return { success: false, error: 'An unexpected error occurred during validation.' }; } } getProviderConfig() { - const serializableProviders = {}; + const config = {}; for (const key in PROVIDERS) { const { handler, ...rest } = PROVIDERS[key]; - serializableProviders[key] = rest; + config[key] = rest; } - return serializableProviders; + return config; } - - async handleValidateKey(provider, key) { - const result = await this.validateApiKey(provider, key); - if (result.success) { - // Use 'local' as placeholder for local services - const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key; - await this.setApiKey(provider, finalKey); - } - return result; - } - + async handleRemoveApiKey(provider) { - console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`); const success = await this.removeApiKey(provider); if (success) { - const selectedModels = this.getSelectedModels(); - if (!selectedModels.llm || !selectedModels.stt) { + const selectedModels = await this.getSelectedModels(); + if (!selectedModels.llm && !selectedModels.stt) { this.emit('force-show-apikey-header'); } } return success; } + /*-------------- Compatibility Helpers --------------*/ + async handleValidateKey(provider, key) { + return await this.setApiKey(provider, key); + } + async handleSetSelectedModel(type, modelId) { - return this.setSelectedModel(type, modelId); + return await this.setSelectedModel(type, modelId); } - /** - * - * @param {('llm' | 'stt')} type - * @returns {{provider: string, model: string, apiKey: string} | null} - */ - getCurrentModelInfo(type) { - this._logCurrentSelection(); - const model = this.state.selectedModels[type]; - if (!model) { - return null; - } - - const provider = this.getProviderForModel(type, model); - if (!provider) { - return null; - } - - const apiKey = this.getApiKey(provider); - return { provider, model, apiKey }; + async areProvidersConfigured() { + if (this.isLoggedInWithFirebase()) return true; + const allSettings = await providerSettingsRepository.getAll(); + const apiKeyMap = {}; + allSettings.forEach(s => apiKeyMap[s.provider] = s.api_key); + // LLM + const hasLlmKey = Object.entries(apiKeyMap).some(([provider, key]) => { + if (!key) return false; + if (provider === 'whisper') return false; // whisper는 LLM 없음 + return PROVIDERS[provider]?.llmModels?.length > 0; + }); + // STT + const hasSttKey = Object.entries(apiKeyMap).some(([provider, key]) => { + if (!key) return false; + if (provider === 'ollama') return false; // ollama는 STT 없음 + return PROVIDERS[provider]?.sttModels?.length > 0 || provider === 'whisper'; + }); + return hasLlmKey && hasSttKey; } - } -// Export singleton instance const modelStateService = new ModelStateService(); module.exports = modelStateService; \ No newline at end of file diff --git a/src/features/common/services/ollamaService.js b/src/features/common/services/ollamaService.js index b4830a0..aecd384 100644 --- a/src/features/common/services/ollamaService.js +++ b/src/features/common/services/ollamaService.js @@ -1066,7 +1066,7 @@ class OllamaService extends EventEmitter { return false; } - const selectedModels = modelStateService.getSelectedModels(); + const selectedModels = await modelStateService.getSelectedModels(); const llmModelId = selectedModels.llm; // Check if it's an Ollama model diff --git a/src/features/common/services/permissionService.js b/src/features/common/services/permissionService.js index 7d3260b..1913990 100644 --- a/src/features/common/services/permissionService.js +++ b/src/features/common/services/permissionService.js @@ -106,6 +106,9 @@ class PermissionService { } async checkKeychainCompleted(uid) { + if (uid === "default_user") { + return true; + } try { const completed = permissionRepository.checkKeychainCompleted(uid); console.log('[Permissions] Keychain completed status:', completed); diff --git a/src/features/common/services/sqliteClient.js b/src/features/common/services/sqliteClient.js index 08c8c9d..7d37e40 100644 --- a/src/features/common/services/sqliteClient.js +++ b/src/features/common/services/sqliteClient.js @@ -40,8 +40,82 @@ class SQLiteClient { return `"${identifier}"`; } + _migrateProviderSettings() { + const tablesInDb = this.getTablesFromDb(); + if (!tablesInDb.includes('provider_settings')) { + return; // Table doesn't exist, no migration needed. + } + + const providerSettingsInfo = this.db.prepare(`PRAGMA table_info(provider_settings)`).all(); + const hasUidColumn = providerSettingsInfo.some(col => col.name === 'uid'); + + if (hasUidColumn) { + console.log('[DB Migration] Old provider_settings schema detected. Starting robust migration...'); + + try { + this.db.transaction(() => { + this.db.exec('ALTER TABLE provider_settings RENAME TO provider_settings_old'); + console.log('[DB Migration] Renamed provider_settings to provider_settings_old'); + + this.createTable('provider_settings', LATEST_SCHEMA.provider_settings); + console.log('[DB Migration] Created new provider_settings table'); + + // Dynamically build the migration query for robustness + const oldColumnNames = this.db.prepare(`PRAGMA table_info(provider_settings_old)`).all().map(c => c.name); + const newColumnNames = LATEST_SCHEMA.provider_settings.columns.map(c => c.name); + const commonColumns = newColumnNames.filter(name => oldColumnNames.includes(name)); + + if (!commonColumns.includes('provider')) { + console.warn('[DB Migration] Old table is missing the "provider" column. Aborting migration for this table.'); + this.db.exec('DROP TABLE provider_settings_old'); + return; + } + + const orderParts = []; + if (oldColumnNames.includes('updated_at')) orderParts.push('updated_at DESC'); + if (oldColumnNames.includes('created_at')) orderParts.push('created_at DESC'); + const orderByClause = orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : ''; + + const columnsForInsert = commonColumns.map(c => this._validateAndQuoteIdentifier(c)).join(', '); + + const migrationQuery = ` + INSERT INTO provider_settings (${columnsForInsert}) + SELECT ${columnsForInsert} + FROM ( + SELECT *, ROW_NUMBER() OVER(PARTITION BY provider ${orderByClause}) as rn + FROM provider_settings_old + ) + WHERE rn = 1 + `; + + console.log(`[DB Migration] Executing robust migration query for columns: ${commonColumns.join(', ')}`); + const result = this.db.prepare(migrationQuery).run(); + console.log(`[DB Migration] Migrated ${result.changes} rows to the new provider_settings table.`); + + this.db.exec('DROP TABLE provider_settings_old'); + console.log('[DB Migration] Dropped provider_settings_old table.'); + })(); + console.log('[DB Migration] provider_settings migration completed successfully.'); + } catch (error) { + console.error('[DB Migration] Failed to migrate provider_settings table.', error); + + // Try to recover by dropping the temp table if it exists + const oldTableExists = this.getTablesFromDb().includes('provider_settings_old'); + if (oldTableExists) { + this.db.exec('DROP TABLE provider_settings_old'); + console.warn('[DB Migration] Cleaned up temporary old table after failure.'); + } + throw error; + } + } + } + async synchronizeSchema() { console.log('[DB Sync] Starting schema synchronization...'); + + // Run special migration for provider_settings before the generic sync logic + this._migrateProviderSettings(); + const tablesInDb = this.getTablesFromDb(); for (const tableName of Object.keys(LATEST_SCHEMA)) { diff --git a/src/features/listen/stt/sttService.js b/src/features/listen/stt/sttService.js index 8acf767..5a986db 100644 --- a/src/features/listen/stt/sttService.js +++ b/src/features/listen/stt/sttService.js @@ -2,7 +2,6 @@ const { BrowserWindow } = require('electron'); const { spawn } = require('child_process'); const { createSTT } = require('../../common/ai/factory'); const modelStateService = require('../../common/services/modelStateService'); -// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager'); const COMPLETION_DEBOUNCE_MS = 2000; @@ -134,7 +133,7 @@ class SttService { async initializeSttSessions(language = 'en') { const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; - const modelInfo = modelStateService.getCurrentModelInfo('stt'); + const modelInfo = await modelStateService.getCurrentModelInfo('stt'); if (!modelInfo || !modelInfo.apiKey) { throw new Error('AI model or API key is not configured.'); } @@ -467,7 +466,7 @@ class SttService { let modelInfo = this.modelInfo; if (!modelInfo) { console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); - modelInfo = modelStateService.getCurrentModelInfo('stt'); + modelInfo = await modelStateService.getCurrentModelInfo('stt'); } if (!modelInfo) { throw new Error('STT model info could not be retrieved.'); @@ -492,7 +491,7 @@ class SttService { let modelInfo = this.modelInfo; if (!modelInfo) { console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); - modelInfo = modelStateService.getCurrentModelInfo('stt'); + modelInfo = await modelStateService.getCurrentModelInfo('stt'); } if (!modelInfo) { throw new Error('STT model info could not be retrieved.'); @@ -578,7 +577,7 @@ class SttService { let modelInfo = this.modelInfo; if (!modelInfo) { console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); - modelInfo = modelStateService.getCurrentModelInfo('stt'); + modelInfo = await modelStateService.getCurrentModelInfo('stt'); } if (!modelInfo) { throw new Error('STT model info could not be retrieved.'); diff --git a/src/features/listen/summary/summaryService.js b/src/features/listen/summary/summaryService.js index 0296d96..9264dad 100644 --- a/src/features/listen/summary/summaryService.js +++ b/src/features/listen/summary/summaryService.js @@ -4,7 +4,6 @@ const { createLLM } = require('../../common/ai/factory'); const sessionRepository = require('../../common/repositories/session'); const summaryRepository = require('./repositories'); const modelStateService = require('../../common/services/modelStateService'); -// const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../window/windowManager.js'); class SummaryService { constructor() { @@ -99,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments. await sessionRepository.touch(this.currentSessionId); } - const modelInfo = modelStateService.getCurrentModelInfo('llm'); + const modelInfo = await modelStateService.getCurrentModelInfo('llm'); if (!modelInfo || !modelInfo.apiKey) { throw new Error('AI model or API key is not configured.'); } diff --git a/src/features/settings/settingsService.js b/src/features/settings/settingsService.js index de617d6..3978b73 100644 --- a/src/features/settings/settingsService.js +++ b/src/features/settings/settingsService.js @@ -26,16 +26,14 @@ const NOTIFICATION_CONFIG = { // New facade functions for model state management async function getModelSettings() { try { - const [config, storedKeys, selectedModels] = await Promise.all([ + const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([ modelStateService.getProviderConfig(), modelStateService.getAllApiKeys(), modelStateService.getSelectedModels(), + modelStateService.getAvailableModels('llm'), + modelStateService.getAvailableModels('stt') ]); - // 동기 함수들은 별도로 호출 - const availableLlm = modelStateService.getAvailableModels('llm'); - const availableStt = modelStateService.getAvailableModels('stt'); - return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } }; } catch (error) { console.error('[SettingsService] Error getting model settings:', error); diff --git a/src/index.js b/src/index.js index 627c560..e5ec966 100644 --- a/src/index.js +++ b/src/index.js @@ -686,75 +686,43 @@ async function startWebStack() { console.log(`✅ API server started on http://localhost:${apiPort}`); - console.log(`🚀 All services ready:`); - console.log(` Frontend: http://localhost:${frontendPort}`); - console.log(` API: http://localhost:${apiPort}`); + console.log(`🚀 All services ready: + Frontend: http://localhost:${frontendPort} + API: http://localhost:${apiPort}`); return frontendPort; } // Auto-update initialization async function initAutoUpdater() { + if (process.env.NODE_ENV === 'development') { + console.log('Development environment, skipping auto-updater.'); + return; + } + try { - const autoUpdateEnabled = await settingsService.getAutoUpdateSetting(); - if (!autoUpdateEnabled) { - console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings'); - return; - } - // Skip auto-updater in development mode - if (!app.isPackaged) { - console.log('[AutoUpdater] Skipped in development (app is not packaged)'); - return; - } - - autoUpdater.setFeedURL({ - provider: 'github', - owner: 'pickle-com', - repo: 'glass', + await autoUpdater.checkForUpdates(); + autoUpdater.on('update-available', () => { + console.log('Update available!'); + autoUpdater.downloadUpdate(); }); - - // Immediately check for updates & notify - autoUpdater.checkForUpdatesAndNotify() - .catch(err => { - console.error('[AutoUpdater] Error checking for updates:', err); - }); - - autoUpdater.on('checking-for-update', () => { - console.log('[AutoUpdater] Checking for updates…'); - }); - - autoUpdater.on('update-available', (info) => { - console.log('[AutoUpdater] Update available:', info.version); - }); - - autoUpdater.on('update-not-available', () => { - console.log('[AutoUpdater] Application is up-to-date'); - }); - - autoUpdater.on('error', (err) => { - console.error('[AutoUpdater] Error while updating:', err); - }); - - autoUpdater.on('update-downloaded', (info) => { - console.log(`[AutoUpdater] Update downloaded: ${info.version}`); - - const dialogOpts = { + autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => { + console.log('Update downloaded:', releaseNotes, releaseName, date, url); + dialog.showMessageBox({ type: 'info', - buttons: ['Install now', 'Install on next launch'], - title: 'Update Available', - message: 'A new version of Glass is ready to be installed.', - defaultId: 0, - cancelId: 1 - }; - - dialog.showMessageBox(dialogOpts).then((returnValue) => { - // returnValue.response 0 is for 'Install Now' - if (returnValue.response === 0) { + title: 'Application Update', + message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`, + buttons: ['Restart', 'Later'] + }).then(response => { + if (response.response === 0) { autoUpdater.quitAndInstall(); } }); }); - } catch (e) { - console.error('[AutoUpdater] Failed to initialise:', e); + autoUpdater.on('error', (err) => { + console.error('Error in auto-updater:', err); + }); + } catch (err) { + console.error('Error initializing auto-updater:', err); } } \ No newline at end of file diff --git a/src/preload.js b/src/preload.js index f83cd03..2cd6f9c 100644 --- a/src/preload.js +++ b/src/preload.js @@ -98,8 +98,6 @@ contextBridge.exposeInMainWorld('api', { removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback), onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback), removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback), - onForceShowPermissionHeader: (callback) => ipcRenderer.on('force-show-permission-header', callback), - removeOnForceShowPermissionHeader: (callback) => ipcRenderer.removeListener('force-show-permission-header', callback) }, // src/ui/app/MainHeader.js diff --git a/src/ui/app/HeaderController.js b/src/ui/app/HeaderController.js index 47a18d1..6202781 100644 --- a/src/ui/app/HeaderController.js +++ b/src/ui/app/HeaderController.js @@ -48,6 +48,9 @@ class HeaderTransitionManager { console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.'); } else if (type === 'permission') { this.permissionHeader = document.createElement('permission-setup'); + this.permissionHeader.addEventListener('request-resize', e => { + this._resizeForPermissionHeader(e.detail.height); + }); this.permissionHeader.continueCallback = async () => { if (window.api && window.api.headerController) { console.log('[HeaderController] Re-initializing model state after permission grant...'); @@ -198,7 +201,19 @@ class HeaderTransitionManager { } } - await this._resizeForPermissionHeader(); + let initialHeight = 220; + if (window.api) { + try { + const userState = await window.api.common.getCurrentUser(); + if (userState.mode === 'firebase') { + initialHeight = 280; + } + } catch (e) { + console.error('Could not get user state for resize', e); + } + } + + await this._resizeForPermissionHeader(initialHeight); this.ensureHeader('permission'); } @@ -223,9 +238,10 @@ class HeaderTransitionManager { return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {}); } - async _resizeForPermissionHeader() { + async _resizeForPermissionHeader(height) { if (!window.api) return; - return window.api.headerController.resizeHeaderWindow({ width: 285, height: 220 }) + const finalHeight = height || 220; + return window.api.headerController.resizeHeaderWindow({ width: 285, height: finalHeight }) .catch(() => {}); } diff --git a/src/ui/app/PermissionHeader.js b/src/ui/app/PermissionHeader.js index b7b7b53..c90a830 100644 --- a/src/ui/app/PermissionHeader.js +++ b/src/ui/app/PermissionHeader.js @@ -28,7 +28,7 @@ export class PermissionHeader extends LitElement { .container { -webkit-app-region: drag; width: 285px; - height: 220px; + /* height is now set dynamically */ padding: 18px 20px; background: rgba(0, 0, 0, 0.3); border-radius: 16px; @@ -103,6 +103,12 @@ export class PermissionHeader extends LitElement { margin-top: auto; } + .form-content.all-granted { + flex-grow: 1; + justify-content: center; + margin-top: 0; + } + .subtitle { color: rgba(255, 255, 255, 0.7); font-size: 11px; @@ -260,7 +266,8 @@ export class PermissionHeader extends LitElement { screenGranted: { type: String }, keychainGranted: { type: String }, isChecking: { type: String }, - continueCallback: { type: Function } + continueCallback: { type: Function }, + userMode: { type: String }, // 'local' or 'firebase' }; constructor() { @@ -270,14 +277,47 @@ export class PermissionHeader extends LitElement { this.keychainGranted = 'unknown'; this.isChecking = false; this.continueCallback = null; + this.userMode = 'local'; // Default to local + } + + updated(changedProperties) { + super.updated(changedProperties); + if (changedProperties.has('userMode')) { + const newHeight = this.userMode === 'firebase' ? 280 : 220; + console.log(`[PermissionHeader] User mode changed to ${this.userMode}, requesting resize to ${newHeight}px`); + this.dispatchEvent(new CustomEvent('request-resize', { + detail: { height: newHeight }, + bubbles: true, + composed: true + })); + } } async connectedCallback() { super.connectedCallback(); + + if (window.api) { + try { + const userState = await window.api.common.getCurrentUser(); + this.userMode = userState.mode; + } catch (e) { + console.error('[PermissionHeader] Failed to get user state', e); + this.userMode = 'local'; // Fallback to local + } + } + await this.checkPermissions(); // Set up periodic permission check - this.permissionCheckInterval = setInterval(() => { + this.permissionCheckInterval = setInterval(async () => { + if (window.api) { + try { + const userState = await window.api.common.getCurrentUser(); + this.userMode = userState.mode; + } catch (e) { + this.userMode = 'local'; + } + } this.checkPermissions(); }, 1000); } @@ -311,11 +351,14 @@ export class PermissionHeader extends LitElement { console.log('[PermissionHeader] Permission status changed, updating UI'); this.requestUpdate(); } + + const isKeychainRequired = this.userMode === 'firebase'; + const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted'; // if all permissions granted == automatically continue if (this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && - this.keychainGranted === 'granted' && + keychainOk && this.continueCallback) { console.log('[PermissionHeader] All permissions granted, proceeding automatically'); setTimeout(() => this.handleContinue(), 500); @@ -405,12 +448,15 @@ export class PermissionHeader extends LitElement { } async handleContinue() { + const isKeychainRequired = this.userMode === 'firebase'; + const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted'; + if (this.continueCallback && this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && - this.keychainGranted === 'granted') { + keychainOk) { // Mark permissions as completed - if (window.api) { + if (window.api && isKeychainRequired) { try { await window.api.permissionHeader.markKeychainCompleted(); console.log('[PermissionHeader] Marked keychain as completed'); @@ -431,10 +477,13 @@ export class PermissionHeader extends LitElement { } render() { - const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && this.keychainGranted === 'granted'; + const isKeychainRequired = this.userMode === 'firebase'; + const containerHeight = isKeychainRequired ? 280 : 220; + const keychainOk = !isKeychainRequired || this.keychainGranted === 'granted'; + const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted' && keychainOk; return html` -