import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js" // import { getOllamaProgressTracker } from "../../features/common/services/localProgressTracker.js" // 제거됨 export class ApiKeyHeader extends LitElement { //////// after_modelStateService //////// static properties = { llmApiKey: { type: String }, sttApiKey: { type: String }, llmProvider: { type: String }, sttProvider: { type: String }, isLoading: { type: Boolean }, errorMessage: { type: String }, successMessage: { type: String }, providers: { type: Object, state: true }, modelSuggestions: { type: Array, state: true }, userModelHistory: { type: Array, state: true }, selectedLlmModel: { type: String, state: true }, selectedSttModel: { type: String, state: true }, ollamaStatus: { type: Object, state: true }, installingModel: { type: String, state: true }, installProgress: { type: Number, state: true }, whisperInstallingModels: { type: Object, state: true }, } //////// after_modelStateService //////// static styles = css` :host { display: block; transition: opacity 0.3s ease-in, transform 0.3s ease-in; will-change: opacity, transform; } :host(.sliding-out) { opacity: 0; transform: translateY(-20px); } :host(.hidden) { opacity: 0; pointer-events: none; } * { font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; cursor: default; user-select: none; box-sizing: border-box; } .container { -webkit-app-region: drag; width: 350px; min-height: 260px; padding: 18px 20px; background: rgba(0, 0, 0, 0.3); border-radius: 16px; overflow: visible; position: relative; display: flex; flex-direction: column; align-items: center; } .container::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 16px; padding: 1px; background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: destination-out; mask-composite: exclude; pointer-events: none; } .close-button { -webkit-app-region: no-drag; position: absolute; top: 10px; right: 10px; width: 14px; height: 14px; background: rgba(255, 255, 255, 0.1); border: none; border-radius: 3px; color: rgba(255, 255, 255, 0.7); cursor: pointer; display: flex; align-items: center; justify-content: center; transition: all 0.15s ease; z-index: 10; font-size: 14px; line-height: 1; padding: 0; } .close-button:hover { background: rgba(255, 255, 255, 0.2); color: rgba(255, 255, 255, 0.9); } .close-button:active { transform: scale(0.95); } .title { color: white; font-size: 16px; font-weight: 500; /* Medium */ margin: 0; text-align: center; flex-shrink: 0; } .form-content { display: flex; flex-direction: column; align-items: center; width: 100%; margin-top: auto; } .error-message { color: rgba(239, 68, 68, 0.9); font-weight: 500; font-size: 11px; height: 14px; text-align: center; margin-bottom: 4px; opacity: 1; transition: opacity 0.3s ease; } .success-message { color: rgba(74, 222, 128, 0.9); font-weight: 500; font-size: 11px; height: 14px; text-align: center; margin-bottom: 4px; opacity: 1; transition: opacity 0.3s ease; } .message-fade-out { animation: fadeOut 3s ease-in-out forwards; } @keyframes fadeOut { 0% { opacity: 1; } 66% { opacity: 1; } 100% { opacity: 0; } } .api-input { -webkit-app-region: no-drag; width: 100%; height: 34px; background: rgba(255, 255, 255, 0.1); border-radius: 10px; border: none; padding: 0 10px; color: white; font-size: 12px; font-weight: 400; /* Regular */ margin-bottom: 6px; text-align: center; user-select: text; cursor: text; } .api-input::placeholder { color: rgba(255, 255, 255, 0.6); } .api-input:focus { outline: none; } .providers-container { display: flex; gap: 12px; width: 100%; } .provider-column { flex: 1; display: flex; flex-direction: column; align-items: center; } .provider-label { color: rgba(255, 255, 255, 0.7); font-size: 11px; font-weight: 500; margin-bottom: 6px; } .api-input, .provider-select { -webkit-app-region: no-drag; width: 100%; height: 34px; text-align: center; background: rgba(255, 255, 255, 0.1); border-radius: 10px; border: 1px solid rgba(255, 255, 255, 0.2); padding: 0 10px; color: white; font-size: 12px; margin-bottom: 6px; } .provider-select option { background: #1a1a1a; color: white; } .provider-select:hover { background-color: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.3); } .provider-select:focus { outline: none; background-color: rgba(255, 255, 255, 0.15); border-color: rgba(255, 255, 255, 0.4); } .action-button { -webkit-app-region: no-drag; width: 100%; height: 34px; background: rgba(255, 255, 255, 0.2); border: none; border-radius: 10px; color: white; font-size: 12px; font-weight: 500; /* Medium */ cursor: pointer; transition: background 0.15s ease; position: relative; overflow: visible; } .action-button::after { content: ''; position: absolute; top: 0; left: 0; right: 0; bottom: 0; border-radius: 10px; padding: 1px; background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%); -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); -webkit-mask-composite: destination-out; mask-composite: exclude; pointer-events: none; } .action-button:hover { background: rgba(255, 255, 255, 0.3); } .action-button:disabled { opacity: 0.5; cursor: not-allowed; } .or-text { color: rgba(255, 255, 255, 0.5); font-size: 12px; font-weight: 500; /* Medium */ margin: 10px 0; } /* ────────────────[ GLASS BYPASS ]─────────────── */ :host-context(body.has-glass) .container, :host-context(body.has-glass) .api-input, :host-context(body.has-glass) .provider-select, :host-context(body.has-glass) .action-button, :host-context(body.has-glass) .close-button { background: transparent !important; border: none !important; box-shadow: none !important; filter: none !important; backdrop-filter: none !important; } :host-context(body.has-glass) .container::after, :host-context(body.has-glass) .action-button::after { display: none !important; } :host-context(body.has-glass) .action-button:hover, :host-context(body.has-glass) .provider-select:hover, :host-context(body.has-glass) .close-button:hover { background: transparent !important; } ` constructor() { super() this.isLoading = false this.errorMessage = "" this.successMessage = "" this.messageTimestamp = 0 //////// after_modelStateService //////// this.llmApiKey = ""; this.sttApiKey = ""; this.llmProvider = "openai"; this.sttProvider = "openai"; this.providers = { llm: [], stt: [] }; // 초기화 // Ollama related this.modelSuggestions = []; this.userModelHistory = []; this.selectedLlmModel = ""; this.selectedSttModel = ""; this.ollamaStatus = { installed: false, running: false }; this.installingModel = null; this.installProgress = 0; this.whisperInstallingModels = {}; // Professional operation management system this.activeOperations = new Map(); this.operationTimeouts = new Map(); this.connectionState = 'idle'; // idle, connecting, connected, failed, disconnected this.lastStateChange = Date.now(); this.retryCount = 0; this.maxRetries = 3; this.baseRetryDelay = 1000; // Backpressure and resource management this.operationQueue = []; this.maxConcurrentOperations = 2; this.maxQueueSize = 5; this.operationMetrics = { totalOperations: 0, successfulOperations: 0, failedOperations: 0, timeouts: 0, averageResponseTime: 0 }; // Configuration this.ipcTimeout = 10000; // 10s for IPC calls this.operationTimeout = 15000; // 15s for complex operations // Health monitoring system this.healthCheck = { enabled: false, intervalId: null, intervalMs: 120000, lastCheck: 0, consecutiveFailures: 0, maxFailures: 5, skipDuringOperation: true // skip during operation }; // Load user model history from localStorage this.loadUserModelHistory(); this.loadProviderConfig(); //////// after_modelStateService //////// this.handleKeyPress = this.handleKeyPress.bind(this) this.handleSubmit = this.handleSubmit.bind(this) this.handleInput = this.handleInput.bind(this) this.handleAnimationEnd = this.handleAnimationEnd.bind(this) this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this) this.handleProviderChange = this.handleProviderChange.bind(this) this.handleLlmProviderChange = this.handleLlmProviderChange.bind(this) this.handleSttProviderChange = this.handleSttProviderChange.bind(this) this.handleMessageFadeEnd = this.handleMessageFadeEnd.bind(this) this.handleModelKeyPress = this.handleModelKeyPress.bind(this) this.handleSttModelChange = this.handleSttModelChange.bind(this) } reset() { this.apiKey = "" this.isLoading = false this.errorMessage = "" this.validatedApiKey = null this.selectedProvider = "openai" this.requestUpdate() } async loadProviderConfig() { if (!window.api) return; try { const [config, ollamaStatus] = await Promise.all([ window.api.apiKeyHeader.getProviderConfig(), window.api.apiKeyHeader.getOllamaStatus() ]); const llmProviders = []; const sttProviders = []; for (const id in config) { // 'openai-glass' 같은 가상 Provider는 UI에 표시하지 않음 if (id.includes('-glass')) continue; const hasLlmModels = config[id].llmModels.length > 0 || id === 'ollama'; const hasSttModels = config[id].sttModels.length > 0 || id === 'whisper'; if (hasLlmModels) { llmProviders.push({ id, name: config[id].name }); } if (hasSttModels) { sttProviders.push({ id, name: config[id].name }); } } this.providers = { llm: llmProviders, stt: sttProviders }; // 기본 선택 값 설정 if (llmProviders.length > 0) this.llmProvider = llmProviders[0].id; if (sttProviders.length > 0) this.sttProvider = sttProviders[0].id; // Ollama 상태 및 모델 제안 로드 if (ollamaStatus?.success) { this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running }; // Load model suggestions if Ollama is running if (ollamaStatus.running) { await this.loadModelSuggestions(); } } this.requestUpdate(); } catch (error) { console.error('[ApiKeyHeader] Failed to load provider config:', error); } } async handleMouseDown(e) { if (e.target.tagName === "INPUT" || e.target.tagName === "BUTTON" || e.target.tagName === "SELECT") { return } e.preventDefault() const initialPosition = await window.api.apiKeyHeader.getHeaderPosition() this.dragState = { initialMouseX: e.screenX, initialMouseY: e.screenY, initialWindowX: initialPosition.x, initialWindowY: initialPosition.y, moved: false, } window.addEventListener("mousemove", this.handleMouseMove) window.addEventListener("mouseup", this.handleMouseUp, { once: true }) } handleMouseMove(e) { if (!this.dragState) return const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX) const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY) if (deltaX > 3 || deltaY > 3) { this.dragState.moved = true } const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX) const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY) window.api.apiKeyHeader.moveHeaderTo(newWindowX, newWindowY) } handleMouseUp(e) { if (!this.dragState) return const wasDragged = this.dragState.moved window.removeEventListener("mousemove", this.handleMouseMove) this.dragState = null if (wasDragged) { this.wasJustDragged = true setTimeout(() => { this.wasJustDragged = false }, 200) } } handleInput(e) { this.apiKey = e.target.value this.clearMessages() console.log("Input changed:", this.apiKey?.length || 0, "chars") this.requestUpdate() this.updateComplete.then(() => { const inputField = this.shadowRoot?.querySelector(".apikey-input") if (inputField && this.isInputFocused) { inputField.focus() } }) } clearMessages() { this.errorMessage = "" this.successMessage = "" this.messageTimestamp = 0 } handleProviderChange(e) { this.selectedProvider = e.target.value this.clearMessages() console.log("Provider changed to:", this.selectedProvider) this.requestUpdate() } async handleLlmProviderChange(e) { // Cancel any active operations first this._cancelAllActiveOperations(); this.llmProvider = e.target.value; this.errorMessage = ""; this.successMessage = ""; // Reset retry state this.retryCount = 0; if (this.llmProvider === 'ollama') { console.log('[ApiKeyHeader] Ollama selected, initiating connection...'); await this._initializeOllamaConnection(); // Start health monitoring for Ollama this._startHealthMonitoring(); } else { this._updateConnectionState('idle', 'Non-Ollama provider selected'); // Stop health monitoring for non-Ollama providers this._stopHealthMonitoring(); } this.requestUpdate(); } async _initializeOllamaConnection() { try { // Progressive connection attempt with exponential backoff await this._attemptOllamaConnection(); } catch (error) { console.error('[ApiKeyHeader] Initial Ollama connection failed:', error.message); if (this.retryCount < this.maxRetries) { const delay = this.baseRetryDelay * Math.pow(2, this.retryCount); console.log(`[ApiKeyHeader] Retrying Ollama connection in ${delay}ms (attempt ${this.retryCount + 1}/${this.maxRetries})`); this.retryCount++; // Use proper Promise-based delay instead of setTimeout await new Promise(resolve => { const retryTimeoutId = setTimeout(() => { this._initializeOllamaConnection(); resolve(); }, delay); // Store timeout for cleanup this.operationTimeouts.set(`retry_${this.retryCount}`, retryTimeoutId); }); } else { this._updateConnectionState('failed', `Connection failed after ${this.maxRetries} attempts`); } } } async _attemptOllamaConnection() { await this.refreshOllamaStatus(); } _cancelAllActiveOperations() { console.log(`[ApiKeyHeader] Cancelling ${this.activeOperations.size} active operations and ${this.operationQueue.length} queued operations`); // Cancel active operations for (const [operationType, operation] of this.activeOperations) { this._cancelOperation(operationType); } // Cancel queued operations for (const queuedOp of this.operationQueue) { queuedOp.reject(new Error(`Operation ${queuedOp.type} cancelled during cleanup`)); } this.operationQueue.length = 0; // Clean up all timeouts for (const [timeoutId, timeout] of this.operationTimeouts) { clearTimeout(timeout); } this.operationTimeouts.clear(); } /** * Get operation metrics for monitoring */ getOperationMetrics() { return { ...this.operationMetrics, activeOperations: this.activeOperations.size, queuedOperations: this.operationQueue.length, successRate: this.operationMetrics.totalOperations > 0 ? (this.operationMetrics.successfulOperations / this.operationMetrics.totalOperations) * 100 : 0 }; } /** * Adaptive backpressure based on system performance */ _adjustBackpressureThresholds() { const metrics = this.getOperationMetrics(); // Reduce concurrent operations if success rate is low if (metrics.successRate < 70 && this.maxConcurrentOperations > 1) { this.maxConcurrentOperations = Math.max(1, this.maxConcurrentOperations - 1); console.log(`[ApiKeyHeader] Reduced max concurrent operations to ${this.maxConcurrentOperations} (success rate: ${metrics.successRate.toFixed(1)}%)`); } // Increase if performance is good if (metrics.successRate > 90 && metrics.averageResponseTime < 3000 && this.maxConcurrentOperations < 3) { this.maxConcurrentOperations++; console.log(`[ApiKeyHeader] Increased max concurrent operations to ${this.maxConcurrentOperations}`); } } /** * Professional health monitoring system */ _startHealthMonitoring() { if (this.healthCheck.enabled) return; this.healthCheck.enabled = true; this.healthCheck.intervalId = setInterval(() => { this._performHealthCheck(); }, this.healthCheck.intervalMs); console.log(`[ApiKeyHeader] Health monitoring started (interval: ${this.healthCheck.intervalMs}ms)`); } _stopHealthMonitoring() { if (!this.healthCheck.enabled) return; this.healthCheck.enabled = false; if (this.healthCheck.intervalId) { clearInterval(this.healthCheck.intervalId); this.healthCheck.intervalId = null; } console.log('[ApiKeyHeader] Health monitoring stopped'); } async _performHealthCheck() { // Only perform health check if Ollama is selected and we're in a stable state if (this.llmProvider !== 'ollama' || this.connectionState === 'connecting') { return; } // skip during operation if (this.healthCheck.skipDuringOperation && ( this.operationQueue.length > 0 || this.connectionState === 'installing' || this.connectionState === 'starting' || Object.keys(this.operationMetrics.activeOperations || {}).length > 0 )) { console.log('[ApiKeyHeader] Skipping health check - other operations in progress'); return; } const now = Date.now(); this.healthCheck.lastCheck = now; try { // Lightweight health check - just ping the service const isHealthy = await this._executeOperation('health_check', async () => { if (!window.api) return false; const result = await window.api.apiKeyHeader.getOllamaStatus(); return result?.success && result?.running; }, { timeout: 5000, priority: 'low' }); if (isHealthy) { this.healthCheck.consecutiveFailures = 0; // Update state if we were previously failed if (this.connectionState === 'failed') { this._updateConnectionState('connected', 'Health check recovered'); } } else { this._handleHealthCheckFailure(); } // Adjust thresholds based on performance this._adjustBackpressureThresholds(); } catch (error) { console.warn('[ApiKeyHeader] Health check failed:', error.message); this._handleHealthCheckFailure(); } } _handleHealthCheckFailure() { this.healthCheck.consecutiveFailures++; if (this.healthCheck.consecutiveFailures >= this.healthCheck.maxFailures) { console.warn(`[ApiKeyHeader] Health check failed ${this.healthCheck.consecutiveFailures} times, marking as disconnected`); this._updateConnectionState('failed', 'Service health check failed'); // Increase health check frequency when having issues this.healthCheck.intervalMs = Math.max(10000, this.healthCheck.intervalMs / 2); this._restartHealthMonitoring(); } } _restartHealthMonitoring() { this._stopHealthMonitoring(); this._startHealthMonitoring(); } /** * Get comprehensive health status */ getHealthStatus() { return { connection: { state: this.connectionState, lastStateChange: this.lastStateChange, timeSinceLastChange: Date.now() - this.lastStateChange }, operations: this.getOperationMetrics(), health: { enabled: this.healthCheck.enabled, lastCheck: this.healthCheck.lastCheck, timeSinceLastCheck: this.healthCheck.lastCheck > 0 ? Date.now() - this.healthCheck.lastCheck : null, consecutiveFailures: this.healthCheck.consecutiveFailures, intervalMs: this.healthCheck.intervalMs }, ollama: { provider: this.llmProvider, status: this.ollamaStatus, selectedModel: this.selectedLlmModel } }; } async handleSttProviderChange(e) { this.sttProvider = e.target.value; this.errorMessage = ""; this.successMessage = ""; if (this.sttProvider === 'ollama') { console.warn('[ApiKeyHeader] Ollama does not support STT yet. Please select Whisper or another provider.'); this.errorMessage = 'Ollama does not support STT yet. Please select Whisper or another STT provider.'; this.messageTimestamp = Date.now(); // Auto-select Whisper if available const whisperProvider = this.providers.stt.find(p => p.id === 'whisper'); if (whisperProvider) { this.sttProvider = 'whisper'; console.log('[ApiKeyHeader] Auto-selected Whisper for STT'); } } this.requestUpdate(); } /** * Professional operation management with backpressure control */ async _executeOperation(operationType, operation, options = {}) { const operationId = `${operationType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; const timeout = options.timeout || this.ipcTimeout; const priority = options.priority || 'normal'; // high, normal, low // Backpressure control if (this.activeOperations.size >= this.maxConcurrentOperations) { if (this.operationQueue.length >= this.maxQueueSize) { throw new Error(`Operation queue full (${this.maxQueueSize}), rejecting ${operationType}`); } console.log(`[ApiKeyHeader] Queuing operation ${operationType} (${this.activeOperations.size} active)`); return this._queueOperation(operationId, operationType, operation, options); } return this._executeImmediately(operationId, operationType, operation, timeout); } async _queueOperation(operationId, operationType, operation, options) { return new Promise((resolve, reject) => { const queuedOperation = { id: operationId, type: operationType, operation, options, resolve, reject, queuedAt: Date.now(), priority: options.priority || 'normal' }; // Insert based on priority (high priority first) if (options.priority === 'high') { this.operationQueue.unshift(queuedOperation); } else { this.operationQueue.push(queuedOperation); } console.log(`[ApiKeyHeader] Queued ${operationType} (queue size: ${this.operationQueue.length})`); }); } async _executeImmediately(operationId, operationType, operation, timeout) { const startTime = Date.now(); this.operationMetrics.totalOperations++; // Check if similar operation is already running if (this.activeOperations.has(operationType)) { console.log(`[ApiKeyHeader] Operation ${operationType} already in progress, cancelling previous`); this._cancelOperation(operationType); } // Create cancellation mechanism const cancellationPromise = new Promise((_, reject) => { const timeoutId = setTimeout(() => { this.operationMetrics.timeouts++; reject(new Error(`Operation ${operationType} timeout after ${timeout}ms`)); }, timeout); this.operationTimeouts.set(operationId, timeoutId); }); const operationPromise = Promise.race([ operation(), cancellationPromise ]); this.activeOperations.set(operationType, { id: operationId, promise: operationPromise, startTime }); try { const result = await operationPromise; this._recordOperationSuccess(startTime); return result; } catch (error) { this._recordOperationFailure(error, operationType); throw error; } finally { this._cleanupOperation(operationId, operationType); this._processQueue(); } } _recordOperationSuccess(startTime) { this.operationMetrics.successfulOperations++; const responseTime = Date.now() - startTime; this._updateAverageResponseTime(responseTime); } _recordOperationFailure(error, operationType) { this.operationMetrics.failedOperations++; if (error.message.includes('timeout')) { console.error(`[ApiKeyHeader] Operation ${operationType} timed out`); this._updateConnectionState('failed', `Timeout: ${error.message}`); } } _updateAverageResponseTime(responseTime) { const totalOps = this.operationMetrics.successfulOperations; this.operationMetrics.averageResponseTime = ((this.operationMetrics.averageResponseTime * (totalOps - 1)) + responseTime) / totalOps; } async _processQueue() { if (this.operationQueue.length === 0 || this.activeOperations.size >= this.maxConcurrentOperations) { return; } const queuedOp = this.operationQueue.shift(); if (!queuedOp) return; const queueTime = Date.now() - queuedOp.queuedAt; console.log(`[ApiKeyHeader] Processing queued operation ${queuedOp.type} (waited ${queueTime}ms)`); try { const result = await this._executeImmediately( queuedOp.id, queuedOp.type, queuedOp.operation, queuedOp.options.timeout || this.ipcTimeout ); queuedOp.resolve(result); } catch (error) { queuedOp.reject(error); } } _cancelOperation(operationType) { const operation = this.activeOperations.get(operationType); if (operation) { this._cleanupOperation(operation.id, operationType); console.log(`[ApiKeyHeader] Cancelled operation: ${operationType}`); } } _cleanupOperation(operationId, operationType) { if (this.operationTimeouts.has(operationId)) { clearTimeout(this.operationTimeouts.get(operationId)); this.operationTimeouts.delete(operationId); } this.activeOperations.delete(operationType); } _updateConnectionState(newState, reason = '') { if (this.connectionState !== newState) { console.log(`[ApiKeyHeader] Connection state: ${this.connectionState} -> ${newState} (${reason})`); this.connectionState = newState; this.lastStateChange = Date.now(); // Update UI based on state this._handleStateChange(newState, reason); } } _handleStateChange(state, reason) { switch (state) { case 'connecting': this.installingModel = 'Connecting to Ollama...'; this.installProgress = 10; break; case 'failed': this.errorMessage = reason || 'Connection failed'; this.installingModel = null; this.installProgress = 0; this.messageTimestamp = Date.now(); break; case 'connected': this.installingModel = null; this.installProgress = 0; break; case 'disconnected': this.ollamaStatus = { installed: false, running: false }; break; } this.requestUpdate(); } async refreshOllamaStatus() { if (!window.api) return; try { this._updateConnectionState('connecting', 'Checking Ollama status'); const result = await this._executeOperation('ollama_status', async () => { return await window.api.apiKeyHeader.getOllamaStatus(); }); if (result?.success) { this.ollamaStatus = { installed: result.installed, running: result.running }; this._updateConnectionState('connected', 'Status updated successfully'); // Load model suggestions if Ollama is running if (result.running) { await this.loadModelSuggestions(); } } else { this._updateConnectionState('failed', result?.error || 'Status check failed'); } } catch (error) { console.error('[ApiKeyHeader] Failed to refresh Ollama status:', error.message); this._updateConnectionState('failed', error.message); } } async loadModelSuggestions() { if (!window.api) return; try { const result = await this._executeOperation('model_suggestions', async () => { return await window.api.apiKeyHeader.getModelSuggestions(); }); if (result?.success) { this.modelSuggestions = result.suggestions || []; // 기본 모델 선택 (설치된 모델 중 첫 번째) if (!this.selectedLlmModel && this.modelSuggestions.length > 0) { const installedModel = this.modelSuggestions.find(m => m.status === 'installed'); if (installedModel) { this.selectedLlmModel = installedModel.name; } } this.requestUpdate(); } else { console.warn('[ApiKeyHeader] Model suggestions request unsuccessful:', result?.error); } } catch (error) { console.error('[ApiKeyHeader] Failed to load model suggestions:', error.message); } } async ensureOllamaReady() { if (!window.api) return false; try { this._updateConnectionState('connecting', 'Ensuring Ollama is ready'); const result = await this._executeOperation('ollama_ensure_ready', async () => { return await window.api.apiKeyHeader.ensureOllamaReady(); }, { timeout: this.operationTimeout }); if (result?.success) { await this.refreshOllamaStatus(); this._updateConnectionState('connected', 'Ollama ready'); return true; } else { const errorMsg = `Failed to setup Ollama: ${result?.error || 'Unknown error'}`; this._updateConnectionState('failed', errorMsg); return false; } } catch (error) { console.error('[ApiKeyHeader] Failed to ensure Ollama ready:', error.message); this._updateConnectionState('failed', `Error setting up Ollama: ${error.message}`); return false; } } async ensureOllamaReadyWithUI() { if (!window.api) return false; this.installingModel = "Setting up Ollama"; this.installProgress = 0; this.clearMessages(); this.requestUpdate(); const progressHandler = (event, data) => { let baseProgress = 0; let stageTotal = 0; switch (data.stage) { case "downloading": baseProgress = 0; stageTotal = 70; break; case "mounting": baseProgress = 70; stageTotal = 10; break; case "installing": baseProgress = 80; stageTotal = 10; break; case "linking": baseProgress = 90; stageTotal = 5; break; case "cleanup": baseProgress = 95; stageTotal = 3; break; case "starting": baseProgress = 98; stageTotal = 2; break; } const overallProgress = baseProgress + (data.progress / 100) * stageTotal; this.installingModel = data.message; this.installProgress = Math.round(overallProgress); this.requestUpdate(); }; let operationCompleted = false; const completionTimeout = setTimeout(async () => { if (!operationCompleted) { console.log("[ApiKeyHeader] Operation timeout, checking status manually..."); await this._handleOllamaSetupCompletion(true); } }, 15000); // 15 second timeout const completionHandler = async (event, result) => { if (operationCompleted) return; operationCompleted = true; clearTimeout(completionTimeout); window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); await this._handleOllamaSetupCompletion(result.success, result.error); }; window.api.apiKeyHeader.onceOllamaInstallComplete(completionHandler); window.api.apiKeyHeader.onOllamaInstallProgress(progressHandler); try { let result; if (!this.ollamaStatus.installed) { console.log("[ApiKeyHeader] Ollama not installed. Starting installation."); result = await window.api.apiKeyHeader.installOllama(); } else { console.log("[ApiKeyHeader] Ollama installed. Starting service."); result = await window.api.apiKeyHeader.startOllamaService(); } // If IPC call succeeds but no event received, handle completion manually if (result?.success && !operationCompleted) { setTimeout(async () => { if (!operationCompleted) { operationCompleted = true; clearTimeout(completionTimeout); await this._handleOllamaSetupCompletion(true); } }, 2000); } } catch (error) { operationCompleted = true; clearTimeout(completionTimeout); console.error("[ApiKeyHeader] Ollama setup failed:", error); window.api.apiKeyHeader.removeOnOllamaInstallProgress(progressHandler); window.api.apiKeyHeader.removeOnceOllamaInstallComplete(completionHandler); await this._handleOllamaSetupCompletion(false, error.message); } } async _handleOllamaSetupCompletion(success, errorMessage = null) { this.installingModel = null; this.installProgress = 0; if (success) { await this.refreshOllamaStatus(); this.successMessage = "✓ Ollama is ready!"; } else { this.errorMessage = `Setup failed: ${errorMessage || "Unknown error"}`; } this.messageTimestamp = Date.now(); this.requestUpdate(); } async handleModelInput(e) { const modelName = e.target.value.trim(); this.selectedLlmModel = modelName; this.clearMessages(); // Save to user history if it's a valid model name if (modelName && modelName.length > 2) { this.saveToUserHistory(modelName); } this.requestUpdate(); } async handleModelKeyPress(e) { if (e.key === 'Enter' && this.selectedLlmModel?.trim()) { e.preventDefault(); console.log(`[ApiKeyHeader] Enter pressed, installing model: ${this.selectedLlmModel}`); // Check if Ollama is ready first const ollamaReady = await this.ensureOllamaReady(); if (!ollamaReady) { this.errorMessage = 'Failed to setup Ollama'; this.messageTimestamp = Date.now(); this.requestUpdate(); return; } // Install the model await this.installModel(this.selectedLlmModel); } } loadUserModelHistory() { try { const saved = localStorage.getItem('ollama-model-history'); if (saved) { this.userModelHistory = JSON.parse(saved); } } catch (error) { console.error('[ApiKeyHeader] Failed to load model history:', error); this.userModelHistory = []; } } saveToUserHistory(modelName) { if (!modelName || !modelName.trim()) return; // Remove if already exists (to move to front) this.userModelHistory = this.userModelHistory.filter(m => m !== modelName); // Add to front this.userModelHistory.unshift(modelName); // Keep only last 20 entries this.userModelHistory = this.userModelHistory.slice(0, 20); // Save to localStorage try { localStorage.setItem('ollama-model-history', JSON.stringify(this.userModelHistory)); } catch (error) { console.error('[ApiKeyHeader] Failed to save model history:', error); } } getCombinedModelSuggestions() { const combined = []; // Add installed models first (from Ollama CLI) for (const model of this.modelSuggestions) { combined.push({ name: model.name, status: 'installed', size: model.size || 'Unknown', source: 'installed' }); } // Add user history models that aren't already installed const installedNames = this.modelSuggestions.map(m => m.name); for (const modelName of this.userModelHistory) { if (!installedNames.includes(modelName)) { combined.push({ name: modelName, status: 'history', size: 'Unknown', source: 'history' }); } } return combined; } async installModel(modelName) { if (!modelName?.trim()) { throw new Error('Invalid model name'); } this.installingModel = modelName; this.installProgress = 0; this.clearMessages(); this.requestUpdate(); let progressHandler = null; try { console.log(`[ApiKeyHeader] Installing model via Ollama REST API: ${modelName}`); // Create robust progress handler with timeout protection progressHandler = (event, data) => { if (data.model === modelName && !this._isOperationCancelled(modelName)) { const progress = Math.round(Math.max(0, Math.min(100, data.progress || 0))); if (progress !== this.installProgress) { this.installProgress = progress; console.log(`[ApiKeyHeader] API Progress: ${progress}% for ${modelName} (${data.status || 'downloading'})`); this.requestUpdate(); } } }; // Set up progress tracking window.api.apiKeyHeader.onOllamaPullProgress(progressHandler); // Execute the model pull with timeout const installPromise = window.api.apiKeyHeader.pullOllamaModel(modelName); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Installation timeout after 10 minutes')), 600000) ); const result = await Promise.race([installPromise, timeoutPromise]); if (result.success) { console.log(`[ApiKeyHeader] Model ${modelName} installed successfully via API`); this.installProgress = 100; this.requestUpdate(); // Brief pause to show completion await new Promise(resolve => setTimeout(resolve, 300)); // Refresh status and show success await this.refreshOllamaStatus(); this.successMessage = `✓ ${modelName} ready`; this.messageTimestamp = Date.now(); } else { throw new Error(result.error || 'Installation failed'); } } catch (error) { console.error(`[ApiKeyHeader] Model installation failed:`, error); this.errorMessage = `Failed: ${error.message}`; this.messageTimestamp = Date.now(); } finally { // Comprehensive cleanup if (progressHandler) { window.api.apiKeyHeader.removeOnOllamaPullProgress(progressHandler); } this.installingModel = null; this.installProgress = 0; this.requestUpdate(); } } _isOperationCancelled(modelName) { return !this.installingModel || this.installingModel !== modelName; } async downloadWhisperModel(modelId) { if (!modelId?.trim()) { console.warn('[ApiKeyHeader] Invalid Whisper model ID'); return; } console.log(`[ApiKeyHeader] Starting Whisper model download: ${modelId}`); // Mark as installing this.whisperInstallingModels = { ...this.whisperInstallingModels, [modelId]: 0 }; this.clearMessages(); this.requestUpdate(); let progressHandler = null; try { // Set up robust progress listener progressHandler = (event, { modelId: id, progress }) => { if (id === modelId) { const cleanProgress = Math.round(Math.max(0, Math.min(100, progress || 0))); this.whisperInstallingModels = { ...this.whisperInstallingModels, [modelId]: cleanProgress }; console.log(`[ApiKeyHeader] Whisper download progress: ${cleanProgress}% for ${modelId}`); this.requestUpdate(); } }; window.api.apiKeyHeader.onWhisperDownloadProgress(progressHandler); // Start download with timeout protection const downloadPromise = window.api.apiKeyHeader.downloadWhisperModel(modelId); const timeoutPromise = new Promise((_, reject) => setTimeout(() => reject(new Error('Download timeout after 10 minutes')), 600000) ); const result = await Promise.race([downloadPromise, timeoutPromise]); if (result?.success) { this.successMessage = `✓ ${modelId} downloaded successfully`; this.messageTimestamp = Date.now(); console.log(`[ApiKeyHeader] Whisper model ${modelId} downloaded successfully`); // Auto-select the downloaded model this.selectedSttModel = modelId; } else { this.errorMessage = `Failed to download ${modelId}: ${result?.error || 'Unknown error'}`; this.messageTimestamp = Date.now(); console.error(`[ApiKeyHeader] Whisper download failed:`, result?.error); } } catch (error) { console.error(`[ApiKeyHeader] Error downloading Whisper model ${modelId}:`, error); this.errorMessage = `Error downloading ${modelId}: ${error.message}`; this.messageTimestamp = Date.now(); } finally { // Cleanup if (progressHandler) { window.api.apiKeyHeader.removeOnWhisperDownloadProgress(progressHandler); } delete this.whisperInstallingModels[modelId]; this.requestUpdate(); } } handlePaste(e) { e.preventDefault() this.clearMessages() const clipboardText = (e.clipboardData || window.clipboardData).getData("text") console.log("Paste event detected:", clipboardText?.substring(0, 10) + "...") if (clipboardText) { this.apiKey = clipboardText.trim() const inputElement = e.target inputElement.value = this.apiKey } this.requestUpdate() this.updateComplete.then(() => { const inputField = this.shadowRoot?.querySelector(".apikey-input") if (inputField) { inputField.focus() inputField.setSelectionRange(inputField.value.length, inputField.value.length) } }) } handleKeyPress(e) { if (e.key === "Enter") { e.preventDefault() this.handleSubmit() } } //////// after_modelStateService //////// async handleSttModelChange(e) { const modelId = e.target.value; this.selectedSttModel = modelId; if (modelId && this.sttProvider === 'whisper') { // Check if model needs to be downloaded const isInstalling = this.whisperInstallingModels[modelId] !== undefined; if (!isInstalling) { console.log(`[ApiKeyHeader] Auto-installing Whisper model: ${modelId}`); await this.downloadWhisperModel(modelId); } } this.requestUpdate(); } async handleSubmit() { console.log('[ApiKeyHeader] handleSubmit: Submitting...'); this.isLoading = true; this.clearMessages(); this.requestUpdate(); try { // Handle LLM provider let llmResult; if (this.llmProvider === 'ollama') { // For Ollama ensure it's ready and validate model selection if (!this.selectedLlmModel?.trim()) { throw new Error('Please enter an Ollama model name'); } const ollamaReady = await this.ensureOllamaReady(); if (!ollamaReady) { throw new Error('Failed to setup Ollama'); } // Check if model is installed, if not install it const selectedModel = this.getCombinedModelSuggestions().find(m => m.name === this.selectedLlmModel); if (!selectedModel || selectedModel.status !== 'installed') { console.log(`[ApiKeyHeader] Installing model ${this.selectedLlmModel}...`); await this.installModel(this.selectedLlmModel); } // Validate Ollama is working llmResult = await window.api.apiKeyHeader.validateKey({ provider: 'ollama', key: 'local' }); if (llmResult.success) { // Set the selected model await window.api.apiKeyHeader.setSelectedModel({ type: 'llm', modelId: this.selectedLlmModel }); } } else { // For other providers, validate API key if (!this.llmApiKey.trim()) { throw new Error('Please enter LLM API key'); } llmResult = await window.api.apiKeyHeader.validateKey({ provider: this.llmProvider, key: this.llmApiKey.trim() }); } // Handle STT provider let sttResult; if (this.sttProvider === 'ollama') { // Ollama doesn't support STT yet, so skip or use same as LLM validation sttResult = { success: true }; } else if (this.sttProvider === 'whisper') { // For Whisper, just validate it's enabled (model download already handled in handleSttModelChange) sttResult = await window.api.apiKeyHeader.validateKey({ provider: 'whisper', key: 'local' }); if (sttResult.success && this.selectedSttModel) { // Set the selected model await window.api.apiKeyHeader.setSelectedModel({ type: 'stt', modelId: this.selectedSttModel }); } } else { // For other providers, validate API key if (!this.sttApiKey.trim()) { throw new Error('Please enter STT API key'); } sttResult = await window.api.apiKeyHeader.validateKey({ provider: this.sttProvider, key: this.sttApiKey.trim() }); } if (llmResult.success && sttResult.success) { console.log('[ApiKeyHeader] handleSubmit: Validation successful.'); this.startSlideOutAnimation(); } else { let errorParts = []; if (!llmResult.success) errorParts.push(`LLM: ${llmResult.error || 'Invalid'}`); if (!sttResult.success) errorParts.push(`STT: ${sttResult.error || 'Invalid'}`); this.errorMessage = errorParts.join(' | '); this.messageTimestamp = Date.now(); } } catch (error) { console.error('[ApiKeyHeader] handleSubmit: Error:', error); this.errorMessage = error.message; this.messageTimestamp = Date.now(); } this.isLoading = false; this.requestUpdate(); } //////// after_modelStateService //////// startSlideOutAnimation() { console.log('[ApiKeyHeader] startSlideOutAnimation: Starting slide out animation.'); this.classList.add("sliding-out") } handleUsePicklesKey(e) { e.preventDefault() console.log("Requesting Firebase authentication from main process...") if (window.api) { window.api.common.startFirebaseAuth() } } handleClose() { console.log("Close button clicked") if (window.api) { window.api.common.quitApplication() } } //////// after_modelStateService //////// handleAnimationEnd(e) { if (e.target !== this || !this.classList.contains('sliding-out')) return; this.classList.remove("sliding-out"); this.classList.add("hidden"); console.log('[ApiKeyHeader] handleAnimationEnd: Transition completed, transitioning to next state...'); if (!window.api) { console.error('[ApiKeyHeader] handleAnimationEnd: window.api not available'); return; } if (!this.stateUpdateCallback) { console.error('[ApiKeyHeader] handleAnimationEnd: stateUpdateCallback not set! This will prevent transition to main window.'); return; } window.api.common.getCurrentUser() .then(userState => { console.log('[ApiKeyHeader] handleAnimationEnd: User state retrieved:', userState); // Additional validation for local providers return window.api.apiKeyHeader.areProvidersConfigured().then(isConfigured => { console.log('[ApiKeyHeader] handleAnimationEnd: Providers configured check:', isConfigured); if (!isConfigured) { console.warn('[ApiKeyHeader] handleAnimationEnd: Providers still not configured, may return to ApiKey screen'); } // Call the state update callback this.stateUpdateCallback(userState); }); }) .catch(error => { console.error('[ApiKeyHeader] handleAnimationEnd: Error during state transition:', error); // Fallback: try to call callback with minimal state if (this.stateUpdateCallback) { console.log('[ApiKeyHeader] handleAnimationEnd: Attempting fallback state transition...'); this.stateUpdateCallback({ isLoggedIn: false }); } }); } //////// after_modelStateService //////// connectedCallback() { super.connectedCallback() // this.addEventListener("animationend", this.handleAnimationEnd) this.addEventListener("transitionend", this.handleAnimationEnd) } handleMessageFadeEnd(e) { if (e.animationName === 'fadeOut') { // Clear the message that finished fading if (e.target.classList.contains('error-message')) { this.errorMessage = ''; } else if (e.target.classList.contains('success-message')) { this.successMessage = ''; } this.messageTimestamp = 0; this.requestUpdate(); } } disconnectedCallback() { super.disconnectedCallback() // this.removeEventListener("animationend", this.handleAnimationEnd) this.removeEventListener("transitionend", this.handleAnimationEnd) // Professional cleanup of all resources this._performCompleteCleanup(); } _performCompleteCleanup() { console.log('[ApiKeyHeader] Performing complete cleanup'); // Stop health monitoring this._stopHealthMonitoring(); // Cancel all active operations this._cancelAllActiveOperations(); // Cancel any ongoing installations when component is destroyed if (this.installingModel) { // this.progressTracker.cancelInstallation(this.installingModel); // 제거됨 } // Cleanup event listeners if (window.api) { window.api.apiKeyHeader.removeAllListeners(); } // Cancel any ongoing downloads const downloadingModels = Object.keys(this.whisperInstallingModels); if (downloadingModels.length > 0) { console.log(`[ApiKeyHeader] Cancelling ${downloadingModels.length} ongoing Whisper downloads`); downloadingModels.forEach(modelId => { delete this.whisperInstallingModels[modelId]; }); } // Reset state this.connectionState = 'disconnected'; this.retryCount = 0; console.log('[ApiKeyHeader] Cleanup completed'); } /** * State machine-based Ollama UI rendering */ _renderOllamaStateUI() { const state = this._getOllamaUIState(); switch (state.type) { case 'connecting': return this._renderConnectingState(state); case 'install_required': return this._renderInstallRequiredState(); case 'start_required': return this._renderStartRequiredState(); case 'ready': return this._renderReadyState(); case 'failed': return this._renderFailedState(state); case 'installing': return this._renderInstallingState(state); default: return this._renderUnknownState(); } } _getOllamaUIState() { // State determination logic if (this.connectionState === 'connecting') { return { type: 'connecting', message: this.installingModel || 'Connecting to Ollama...' }; } if (this.connectionState === 'failed') { return { type: 'failed', message: this.errorMessage }; } if (this.installingModel && this.installingModel.includes('Ollama')) { return { type: 'installing', progress: this.installProgress }; } if (!this.ollamaStatus.installed) { return { type: 'install_required' }; } if (!this.ollamaStatus.running) { return { type: 'start_required' }; } return { type: 'ready' }; } _renderConnectingState(state) { return html`