1931 lines
66 KiB
JavaScript
1931 lines
66 KiB
JavaScript
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.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.api || !window.api.apikey) return;
|
||
|
||
try {
|
||
const [config, ollamaStatus] = await Promise.all([
|
||
window.api.apikey.getProviderConfig(),
|
||
window.api.apikey.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()
|
||
|
||
if (!window.api || !window.api.apikey) return;
|
||
const initialPosition = await window.api.apikey.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)
|
||
|
||
if (window.api && window.api.apikey) {
|
||
window.api.apikey.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;
|
||
}
|
||
|
||
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 || !window.api.apikey) return false;
|
||
const result = await window.api.apikey.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 || !window.api.apikey) return;
|
||
|
||
try {
|
||
this._updateConnectionState('connecting', 'Checking Ollama status');
|
||
|
||
const result = await this._executeOperation('ollama_status', async () => {
|
||
return await window.api.apikey.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 || !window.api.apikey) return;
|
||
|
||
try {
|
||
const result = await this._executeOperation('model_suggestions', async () => {
|
||
return await window.api.apikey.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 || !window.api.apikey) return false;
|
||
|
||
try {
|
||
this._updateConnectionState('connecting', 'Ensuring Ollama is ready');
|
||
|
||
const result = await this._executeOperation('ollama_ensure_ready', async () => {
|
||
return await window.api.apikey.ensureReady();
|
||
}, { 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 || !window.api.apikey) 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.apikey.removeOnOllamaInstallProgress(progressHandler);
|
||
await this._handleOllamaSetupCompletion(result.success, result.error);
|
||
};
|
||
|
||
window.api.apikey.onOllamaInstallComplete(completionHandler);
|
||
window.api.apikey.onOllamaInstallProgress(progressHandler);
|
||
|
||
try {
|
||
let result;
|
||
if (!this.ollamaStatus.installed) {
|
||
console.log("[ApiKeyHeader] Ollama not installed. Starting installation.");
|
||
result = await window.api.apikey.installOllama();
|
||
} else {
|
||
console.log("[ApiKeyHeader] Ollama installed. Starting service.");
|
||
result = await window.api.apikey.startService();
|
||
}
|
||
|
||
// 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.apikey.removeOnOllamaInstallProgress(progressHandler);
|
||
window.api.apikey.removeOnOllamaInstallComplete(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();
|
||
|
||
if (!window.api || !window.api.apikey) return;
|
||
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.apikey.onOllamaPullProgress(progressHandler);
|
||
|
||
// Execute the model pull with timeout
|
||
const installPromise = window.api.apikey.pullModel(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.apikey.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();
|
||
|
||
if (!window.api || !window.api.apikey) return;
|
||
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.apikey.onWhisperDownloadProgress(progressHandler);
|
||
|
||
// Start download with timeout protection
|
||
const downloadPromise = window.api.apikey.downloadModel(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.apikey.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();
|
||
|
||
if (!window.api || !window.api.apikey) return;
|
||
|
||
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.apikey.validateKey({
|
||
provider: 'ollama',
|
||
key: 'local'
|
||
});
|
||
|
||
if (llmResult.success) {
|
||
// Set the selected model
|
||
await window.api.apikey.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.apikey.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.apikey.validateKey({
|
||
provider: 'whisper',
|
||
key: 'local'
|
||
});
|
||
|
||
if (sttResult.success && this.selectedSttModel) {
|
||
// Set the selected model
|
||
await window.api.apikey.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.apikey.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.apikey) {
|
||
window.api.apikey.startFirebaseAuth()
|
||
}
|
||
}
|
||
|
||
handleClose() {
|
||
console.log("Close button clicked")
|
||
if (window.api && window.api.apikey) {
|
||
window.api.apikey.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: Animation completed, transitioning to next state...');
|
||
|
||
if (!window.api || !window.api.apikey) {
|
||
console.error('[ApiKeyHeader] handleAnimationEnd: window.api.apikey not available');
|
||
return;
|
||
}
|
||
|
||
if (!this.stateUpdateCallback) {
|
||
console.error('[ApiKeyHeader] handleAnimationEnd: stateUpdateCallback not set! This will prevent transition to main window.');
|
||
return;
|
||
}
|
||
|
||
window.api.apikey.getCurrentUser()
|
||
.then(userState => {
|
||
console.log('[ApiKeyHeader] handleAnimationEnd: User state retrieved:', userState);
|
||
|
||
// Additional validation for local providers
|
||
return window.api.apikey.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)
|
||
}
|
||
|
||
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.api && window.api.apikey) {
|
||
window.api.apikey.removeAllListeners('whisper:download-progress');
|
||
window.api.apikey.removeAllListeners('ollama:install-progress');
|
||
window.api.apikey.removeAllListeners('ollama:pull-progress');
|
||
window.api.apikey.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)
|