glass/src/app/ApiKeyHeader.js

1939 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js"
import { getOllamaProgressTracker } from "../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.progressTracker = getOllamaProgressTracker();
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: 30000, // 30s
lastCheck: 0,
consecutiveFailures: 0,
maxFailures: 3
};
// 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.require) return;
const { ipcRenderer } = window.require('electron');
try {
const [config, ollamaStatus] = await Promise.all([
ipcRenderer.invoke('model:get-provider-config'),
ipcRenderer.invoke('ollama:get-status')
]);
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 { ipcRenderer } = window.require("electron")
const initialPosition = await ipcRenderer.invoke("get-header-position")
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)
const { ipcRenderer } = window.require("electron")
ipcRenderer.invoke("move-header-to", 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;
}
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.require) return false;
const { ipcRenderer } = window.require('electron');
const result = await ipcRenderer.invoke('ollama:get-status');
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.require) return;
try {
this._updateConnectionState('connecting', 'Checking Ollama status');
const result = await this._executeOperation('ollama_status', async () => {
const { ipcRenderer } = window.require('electron');
return await ipcRenderer.invoke('ollama:get-status');
});
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.require) return;
try {
const result = await this._executeOperation('model_suggestions', async () => {
const { ipcRenderer } = window.require('electron');
return await ipcRenderer.invoke('ollama:get-model-suggestions');
});
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.require) return false;
try {
this._updateConnectionState('connecting', 'Ensuring Ollama is ready');
const result = await this._executeOperation('ollama_ensure_ready', async () => {
const { ipcRenderer } = window.require('electron');
return await ipcRenderer.invoke('ollama:ensure-ready');
}, { 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.require) return false;
const { ipcRenderer } = window.require("electron");
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);
ipcRenderer.removeListener("ollama:install-progress", progressHandler);
await this._handleOllamaSetupCompletion(result.success, result.error);
};
ipcRenderer.once("ollama:install-complete", completionHandler);
ipcRenderer.on("ollama:install-progress", progressHandler);
try {
let result;
if (!this.ollamaStatus.installed) {
console.log("[ApiKeyHeader] Ollama not installed. Starting installation.");
result = await ipcRenderer.invoke("ollama:install");
} else {
console.log("[ApiKeyHeader] Ollama installed. Starting service.");
result = await ipcRenderer.invoke("ollama:start-service");
}
// 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);
ipcRenderer.removeListener("ollama:install-progress", progressHandler);
ipcRenderer.removeListener("ollama:install-complete", 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();
const { ipcRenderer } = window.require('electron');
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
ipcRenderer.on('ollama:pull-progress', progressHandler);
// Execute the model pull with timeout
const installPromise = ipcRenderer.invoke('ollama:pull-model', 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) {
ipcRenderer.removeListener('ollama:pull-progress', 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();
const { ipcRenderer } = window.require('electron');
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();
}
};
ipcRenderer.on('whisper:download-progress', progressHandler);
// Start download with timeout protection
const downloadPromise = ipcRenderer.invoke('whisper:download-model', 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) {
ipcRenderer.removeListener('whisper:download-progress', 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();
const { ipcRenderer } = window.require('electron');
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 ipcRenderer.invoke('model:validate-key', {
provider: 'ollama',
key: 'local'
});
if (llmResult.success) {
// Set the selected model
await ipcRenderer.invoke('model:set-selected-model', {
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 ipcRenderer.invoke('model:validate-key', {
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 ipcRenderer.invoke('model:validate-key', {
provider: 'whisper',
key: 'local'
});
if (sttResult.success && this.selectedSttModel) {
// Set the selected model
await ipcRenderer.invoke('model:set-selected-model', {
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 ipcRenderer.invoke('model:validate-key', {
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.require) {
window.require("electron").ipcRenderer.invoke("start-firebase-auth")
}
}
handleClose() {
console.log("Close button clicked")
if (window.require) {
window.require("electron").ipcRenderer.invoke("quit-application")
}
}
//////// 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: Animation completed, transitioning to next state...');
if (!window.require) {
console.error('[ApiKeyHeader] handleAnimationEnd: window.require not available');
return;
}
if (!this.stateUpdateCallback) {
console.error('[ApiKeyHeader] handleAnimationEnd: stateUpdateCallback not set! This will prevent transition to main window.');
return;
}
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('get-current-user')
.then(userState => {
console.log('[ApiKeyHeader] handleAnimationEnd: User state retrieved:', userState);
// Additional validation for local providers
return ipcRenderer.invoke('model:are-providers-configured').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)
}
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)
// 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.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeAllListeners('whisper:download-progress');
ipcRenderer.removeAllListeners('ollama:install-progress');
ipcRenderer.removeAllListeners('ollama:pull-progress');
ipcRenderer.removeAllListeners('ollama:install-complete');
}
// 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`
<div style="margin-top: 3px; display: flex; align-items: center; gap: 6px;">
<div style="height: 1px; background: rgba(255,255,255,0.3); border-radius: 0.5px; overflow: hidden; flex: 1;">
<div style="height: 100%; background: rgba(0,122,255,1); width: ${this.installProgress}%; transition: width 0.1s ease;"></div>
</div>
<div style="font-size: 8px; color: rgba(255,255,255,0.8); font-weight: 600; min-width: 24px; text-align: right;">
${this.installProgress}%
</div>
</div>
`;
}
_renderInstallRequiredState() {
return html`
<button class="action-button" style="margin-top: 6px; height: auto; padding: 8px; background: rgba(0,122,255,0.2);" @click=${this.ensureOllamaReadyWithUI}>
Install Ollama
</button>
`;
}
_renderStartRequiredState() {
return html`
<button class="action-button" style="margin-top: 6px; height: auto; padding: 8px; background: rgba(255,200,0,0.2);" @click=${this.ensureOllamaReadyWithUI}>
Start Ollama Service
</button>
`;
}
_renderReadyState() {
return html`
<!-- Model Input with Autocomplete -->
<input
type="text"
class="api-input"
placeholder="Model name (press Enter to install)"
.value=${this.selectedLlmModel}
@input=${this.handleModelInput}
@keypress=${this.handleModelKeyPress}
list="model-suggestions"
?disabled=${this.isLoading || this.installingModel}
style="text-align: left; padding-left: 12px;"
>
<datalist id="model-suggestions">
${this.getCombinedModelSuggestions().map(model => html`
<option value=${model.name}>
${model.name} ${model.status === 'installed' ? '✓ Installed' :
model.status === 'history' ? '📝 Recent' : '- Available'}
</option>
`)}
</datalist>
<!-- Show model status -->
${this.renderModelStatus()}
${this.installingModel && !this.installingModel.includes('Ollama') ? html`
<div style="margin-top: 3px; display: flex; align-items: center; gap: 6px;">
<div style="height: 1px; background: rgba(255,255,255,0.3); border-radius: 0.5px; overflow: hidden; flex: 1;">
<div style="height: 100%; background: rgba(0,122,255,1); width: ${this.installProgress}%; transition: width 0.1s ease;"></div>
</div>
<div style="font-size: 8px; color: rgba(255,255,255,0.8); font-weight: 600; min-width: 24px; text-align: right;">
${this.installProgress}%
</div>
</div>
` : ''}
`;
}
_renderFailedState(state) {
return html`
<div style="margin-top: 6px; padding: 8px; background: rgba(239,68,68,0.1); border-radius: 8px;">
<div style="font-size: 11px; color: rgba(239,68,68,0.8); margin-bottom: 4px; text-align: center;">
Connection failed
</div>
<div style="font-size: 10px; color: rgba(239,68,68,0.6); text-align: center; margin-bottom: 6px;">
${state.message || 'Unknown error'}
</div>
<button class="action-button" style="width: 100%; height: 28px; font-size: 10px; background: rgba(239,68,68,0.2);" @click=${() => this._initializeOllamaConnection()}>
Retry Connection
</button>
</div>
`;
}
_renderInstallingState(state) {
return html`
<div style="margin-top: 3px; display: flex; align-items: center; gap: 6px;">
<div style="height: 1px; background: rgba(255,255,255,0.3); border-radius: 0.5px; overflow: hidden; flex: 1;">
<div style="height: 100%; background: rgba(0,122,255,1); width: ${state.progress}%; transition: width 0.1s ease;"></div>
</div>
<div style="font-size: 8px; color: rgba(255,255,255,0.8); font-weight: 600; min-width: 24px; text-align: right;">
${state.progress}%
</div>
</div>
`;
}
_renderUnknownState() {
return html`
<div style="margin-top: 6px; padding: 8px; background: rgba(255,200,0,0.1); border-radius: 8px;">
<div style="font-size: 11px; color: rgba(255,200,0,0.8); text-align: center;">
Unknown state - Please refresh
</div>
</div>
`;
}
renderModelStatus() {
return '';
}
shouldFadeMessage(type) {
const hasMessage = type === 'error' ? this.errorMessage : this.successMessage;
return hasMessage && this.messageTimestamp > 0 && (Date.now() - this.messageTimestamp) > 100;
}
render() {
// Check if providers are selected and determine validation requirements
const llmNeedsApiKey = this.llmProvider !== 'ollama' && this.llmProvider !== 'whisper';
const sttNeedsApiKey = this.sttProvider !== 'ollama' && this.sttProvider !== 'whisper';
const llmNeedsModel = this.llmProvider === 'ollama';
const sttNeedsModel = this.sttProvider === 'whisper';
// Simplified button disabled logic
const isButtonDisabled = this.isLoading ||
this.installingModel ||
Object.keys(this.whisperInstallingModels).length > 0 ||
(llmNeedsApiKey && !this.llmApiKey.trim()) ||
(sttNeedsApiKey && !this.sttApiKey.trim()) ||
(llmNeedsModel && !this.selectedLlmModel?.trim()) ||
(sttNeedsModel && !this.selectedSttModel);
return html`
<div class="container" @mousedown=${this.handleMouseDown}>
<button class="close-button" @click=${this.handleClose}>×</button>
<h1 class="title">Configure AI Models</h1>
<div class="providers-container">
<div class="provider-column">
<div class="provider-label">LLM Provider</div>
<select class="provider-select" .value=${this.llmProvider} @change=${this.handleLlmProviderChange} ?disabled=${this.isLoading}>
${this.providers.llm.map(p => html`<option value=${p.id}>${p.name}</option>`)}
</select>
${this.llmProvider === 'ollama' ? this._renderOllamaStateUI() : html`
<!-- Regular API Key Input -->
<input type="password" class="api-input" placeholder="LLM Provider API Key" .value=${this.llmApiKey} @input=${e => this.llmApiKey = e.target.value} ?disabled=${this.isLoading}>
`}
</div>
<div class="provider-column">
<div class="provider-label">STT Provider</div>
<select class="provider-select" .value=${this.sttProvider} @change=${this.handleSttProviderChange} ?disabled=${this.isLoading}>
${this.providers.stt.map(p => html`<option value=${p.id}>${p.name}</option>`)}
</select>
${this.sttProvider === 'ollama' ? html`
<!-- Ollama doesn't support STT yet -->
<div style="padding: 8px; background: rgba(255,200,0,0.1); border-radius: 10px; font-size: 11px; color: rgba(255,200,0,0.8); text-align: center;">
STT not supported by Ollama
</div>
` : this.sttProvider === 'whisper' ? html`
<!-- Whisper Model Selection -->
<select
class="api-input"
style="text-align: left; padding-left: 12px;"
.value=${this.selectedSttModel || ''}
@change=${this.handleSttModelChange}
?disabled=${this.isLoading}
>
<option value="">Select a model...</option>
${[
{ id: 'whisper-tiny', name: 'Whisper Tiny', size: '39M' },
{ id: 'whisper-base', name: 'Whisper Base', size: '74M' },
{ id: 'whisper-small', name: 'Whisper Small', size: '244M' },
{ id: 'whisper-medium', name: 'Whisper Medium', size: '769M' }
].map(model => {
const isInstalling = this.whisperInstallingModels[model.id] !== undefined;
const progress = this.whisperInstallingModels[model.id] || 0;
let statusText = '';
if (isInstalling) {
statusText = ` (Downloading ${progress}%)`;
}
return html`
<option value="${model.id}" ?disabled=${isInstalling}>
${model.name} (${model.size})${statusText}
</option>
`;
})}
</select>
${Object.entries(this.whisperInstallingModels).map(([modelId, progress]) => {
if (progress !== undefined) {
return html`
<div style="margin-top: 3px; display: flex; align-items: center; gap: 6px;">
<div style="height: 1px; background: rgba(255,255,255,0.3); border-radius: 0.5px; overflow: hidden; flex: 1;">
<div style="height: 100%; background: rgba(0,122,255,1); width: ${progress}%; transition: width 0.1s ease;"></div>
</div>
<div style="font-size: 8px; color: rgba(255,255,255,0.8); font-weight: 600; min-width: 24px; text-align: right;">
${progress}%
</div>
</div>
`;
}
return '';
})}
` : html`
<!-- Regular API Key Input -->
<input type="password" class="api-input" placeholder="STT Provider API Key" .value=${this.sttApiKey} @input=${e => this.sttApiKey = e.target.value} ?disabled=${this.isLoading}>
`}
</div>
</div>
<div class="error-message ${this.shouldFadeMessage('error') ? 'message-fade-out' : ''}"
@animationend=${this.handleMessageFadeEnd}>
${this.errorMessage}
</div>
<div class="success-message ${this.shouldFadeMessage('success') ? 'message-fade-out' : ''}"
@animationend=${this.handleMessageFadeEnd}>
${this.successMessage}
</div>
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled}>
${this.isLoading ? "Setting up..." :
this.installingModel && this.installingModel.includes("Ollama") ? this.installingModel + "..." :
this.installingModel ? `Installing ${this.installingModel}...` :
Object.keys(this.whisperInstallingModels).length > 0 ? `Downloading Whisper model...` :
"Confirm"}
</button>
<div class="or-text">or</div>
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's Key (Login)</button>
</div>
`;
}
}
customElements.define("apikey-header", ApiKeyHeader)