647 lines
22 KiB
JavaScript
647 lines
22 KiB
JavaScript
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||
|
||
export class ApiKeyHeader extends LitElement {
|
||
static properties = {
|
||
apiKey: { type: String },
|
||
isLoading: { type: Boolean },
|
||
errorMessage: { type: String },
|
||
selectedProvider: { type: String },
|
||
};
|
||
|
||
static styles = css`
|
||
:host {
|
||
display: block;
|
||
transform: translate3d(0, 0, 0);
|
||
backface-visibility: hidden;
|
||
transition: opacity 0.25s ease-out;
|
||
}
|
||
|
||
:host(.sliding-out) {
|
||
animation: slideOutUp 0.3s ease-in forwards;
|
||
will-change: opacity, transform;
|
||
}
|
||
|
||
:host(.hidden) {
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
|
||
@keyframes slideOutUp {
|
||
from {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
to {
|
||
opacity: 0;
|
||
transform: translateY(-20px);
|
||
}
|
||
}
|
||
|
||
* {
|
||
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
cursor: default;
|
||
user-select: none;
|
||
box-sizing: border-box;
|
||
}
|
||
|
||
.container {
|
||
width: 285px;
|
||
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 {
|
||
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;
|
||
}
|
||
|
||
.api-input {
|
||
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;
|
||
}
|
||
|
||
.provider-select {
|
||
width: 100%;
|
||
height: 34px;
|
||
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;
|
||
font-weight: 400;
|
||
margin-bottom: 6px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
-webkit-appearance: none;
|
||
-moz-appearance: none;
|
||
appearance: none;
|
||
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2714%27%20height%3D%278%27%20viewBox%3D%270%200%2014%208%27%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%3E%3Cpath%20d%3D%27M1%201l6%206%206-6%27%20stroke%3D%27%23ffffff%27%20stroke-width%3D%271.5%27%20fill%3D%27none%27%20fill-rule%3D%27evenodd%27/%3E%3C/svg%3E');
|
||
background-repeat: no-repeat;
|
||
background-position: right 10px center;
|
||
background-size: 12px;
|
||
padding-right: 30px;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.provider-select option {
|
||
background: #1a1a1a;
|
||
color: white;
|
||
padding: 5px;
|
||
}
|
||
|
||
.action-button {
|
||
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;
|
||
}
|
||
|
||
.provider-label {
|
||
color: rgba(255, 255, 255, 0.7);
|
||
font-size: 11px;
|
||
font-weight: 400;
|
||
margin-bottom: 4px;
|
||
width: 100%;
|
||
text-align: left;
|
||
}
|
||
`;
|
||
|
||
constructor() {
|
||
super();
|
||
this.dragState = null;
|
||
this.wasJustDragged = false;
|
||
this.apiKey = '';
|
||
this.isLoading = false;
|
||
this.errorMessage = '';
|
||
this.validatedApiKey = null;
|
||
this.selectedProvider = 'openai';
|
||
|
||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||
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.checkAndRequestPermissions = this.checkAndRequestPermissions.bind(this);
|
||
}
|
||
|
||
reset() {
|
||
this.apiKey = '';
|
||
this.isLoading = false;
|
||
this.errorMessage = '';
|
||
this.validatedApiKey = null;
|
||
this.selectedProvider = 'openai';
|
||
this.requestUpdate();
|
||
}
|
||
|
||
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.errorMessage = '';
|
||
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();
|
||
}
|
||
});
|
||
}
|
||
|
||
handleProviderChange(e) {
|
||
this.selectedProvider = e.target.value;
|
||
this.errorMessage = '';
|
||
console.log('Provider changed to:', this.selectedProvider);
|
||
this.requestUpdate();
|
||
}
|
||
|
||
handlePaste(e) {
|
||
e.preventDefault();
|
||
this.errorMessage = '';
|
||
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();
|
||
}
|
||
}
|
||
|
||
async handleSubmit() {
|
||
if (this.wasJustDragged || this.isLoading || !this.apiKey.trim()) {
|
||
console.log('Submit blocked:', {
|
||
wasJustDragged: this.wasJustDragged,
|
||
isLoading: this.isLoading,
|
||
hasApiKey: !!this.apiKey.trim(),
|
||
});
|
||
return;
|
||
}
|
||
|
||
console.log('Starting API key validation...');
|
||
this.isLoading = true;
|
||
this.errorMessage = '';
|
||
this.requestUpdate();
|
||
|
||
const apiKey = this.apiKey.trim();
|
||
let isValid = false;
|
||
try {
|
||
const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider);
|
||
|
||
if (isValid) {
|
||
console.log('API key valid – checking system permissions…');
|
||
const permissionResult = await this.checkAndRequestPermissions();
|
||
|
||
if (permissionResult.success) {
|
||
console.log('All permissions granted – starting slide-out animation');
|
||
this.startSlideOutAnimation();
|
||
this.validatedApiKey = this.apiKey.trim();
|
||
this.validatedProvider = this.selectedProvider;
|
||
} else {
|
||
this.errorMessage = permissionResult.error || 'Permission setup required';
|
||
console.log('Permission setup incomplete:', permissionResult);
|
||
}
|
||
} else {
|
||
this.errorMessage = 'Invalid API key - please check and try again';
|
||
console.log('API key validation failed');
|
||
}
|
||
} catch (error) {
|
||
console.error('API key validation error:', error);
|
||
this.errorMessage = 'Validation error - please try again';
|
||
} finally {
|
||
this.isLoading = false;
|
||
this.requestUpdate();
|
||
}
|
||
}
|
||
|
||
async validateApiKey(apiKey, provider = 'openai') {
|
||
if (!apiKey || apiKey.length < 15) return false;
|
||
|
||
if (provider === 'openai') {
|
||
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
|
||
|
||
try {
|
||
console.log('Validating OpenAI API key...');
|
||
|
||
const response = await fetch('https://api.openai.com/v1/models', {
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
},
|
||
});
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
|
||
const hasGPTModels = data.data && data.data.some(m => m.id.startsWith('gpt-'));
|
||
if (hasGPTModels) {
|
||
console.log('OpenAI API key validation successful');
|
||
return true;
|
||
} else {
|
||
console.log('API key valid but no GPT models available');
|
||
return false;
|
||
}
|
||
} else {
|
||
const errorData = await response.json().catch(() => ({}));
|
||
console.log('API key validation failed:', response.status, errorData.error?.message || 'Unknown error');
|
||
return false;
|
||
}
|
||
} catch (error) {
|
||
console.error('API key validation network error:', error);
|
||
return apiKey.length >= 20; // Fallback for network issues
|
||
}
|
||
} else if (provider === 'gemini') {
|
||
// Gemini API keys typically start with 'AIza'
|
||
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
|
||
|
||
try {
|
||
console.log('Validating Gemini API key...');
|
||
|
||
// Test the API key with a simple models list request
|
||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
|
||
|
||
if (response.ok) {
|
||
const data = await response.json();
|
||
if (data.models && data.models.length > 0) {
|
||
console.log('Gemini API key validation successful');
|
||
return true;
|
||
}
|
||
}
|
||
|
||
console.log('Gemini API key validation failed');
|
||
return false;
|
||
} catch (error) {
|
||
console.error('Gemini API key validation network error:', error);
|
||
return apiKey.length >= 20; // Fallback
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
async checkAndRequestPermissions() {
|
||
if (!window.require) return { success: true };
|
||
|
||
const { ipcRenderer } = window.require('electron');
|
||
|
||
try {
|
||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||
console.log('[Permissions] Current status:', permissions);
|
||
|
||
if (!permissions.needsSetup) return { success: true };
|
||
|
||
if (!permissions.microphone) {
|
||
console.log('[Permissions] Requesting microphone permission…');
|
||
const micResult = await ipcRenderer.invoke('request-microphone-permission');
|
||
if (!micResult.success) {
|
||
await ipcRenderer.invoke('open-system-preferences', 'microphone');
|
||
return {
|
||
success: false,
|
||
error: 'Please grant microphone access in System Preferences',
|
||
};
|
||
}
|
||
}
|
||
|
||
if (!permissions.screen) {
|
||
console.log('[Permissions] Screen-recording permission needed');
|
||
await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
|
||
return {
|
||
success: false,
|
||
error: 'Please grant screen recording access in System Preferences',
|
||
};
|
||
}
|
||
|
||
return { success: true };
|
||
} catch (err) {
|
||
console.error('[Permissions] Error checking/requesting permissions:', err);
|
||
return { success: false, error: 'Failed to check permissions' };
|
||
}
|
||
}
|
||
|
||
startSlideOutAnimation() {
|
||
this.classList.add('sliding-out');
|
||
}
|
||
|
||
handleUsePicklesKey(e) {
|
||
e.preventDefault();
|
||
if (this.wasJustDragged) return;
|
||
|
||
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');
|
||
}
|
||
}
|
||
|
||
handleAnimationEnd(e) {
|
||
if (e.target !== this) return;
|
||
|
||
if (this.classList.contains('sliding-out')) {
|
||
this.classList.remove('sliding-out');
|
||
this.classList.add('hidden');
|
||
|
||
if (this.validatedApiKey) {
|
||
if (window.require) {
|
||
window.require('electron').ipcRenderer.invoke('api-key-validated', {
|
||
apiKey: this.validatedApiKey,
|
||
provider: this.validatedProvider || 'openai'
|
||
});
|
||
}
|
||
this.validatedApiKey = null;
|
||
this.validatedProvider = null;
|
||
}
|
||
}
|
||
}
|
||
|
||
connectedCallback() {
|
||
super.connectedCallback();
|
||
this.addEventListener('animationend', this.handleAnimationEnd);
|
||
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
super.disconnectedCallback();
|
||
this.removeEventListener('animationend', this.handleAnimationEnd);
|
||
|
||
}
|
||
|
||
render() {
|
||
const isButtonDisabled = this.isLoading || !this.apiKey || !this.apiKey.trim();
|
||
console.log('Rendering with provider:', this.selectedProvider);
|
||
|
||
return html`
|
||
<div class="container" @mousedown=${this.handleMouseDown}>
|
||
<button class="close-button" @click=${this.handleClose} title="Close application">
|
||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
|
||
</svg>
|
||
</button>
|
||
<h1 class="title">Choose how to power your AI</h1>
|
||
|
||
<div class="form-content">
|
||
<div class="error-message">${this.errorMessage}</div>
|
||
<div class="provider-label">Select AI Provider:</div>
|
||
<select
|
||
class="provider-select"
|
||
.value=${this.selectedProvider || 'openai'}
|
||
@change=${this.handleProviderChange}
|
||
?disabled=${this.isLoading}
|
||
tabindex="0"
|
||
>
|
||
<option value="openai" ?selected=${this.selectedProvider === 'openai'}>OpenAI</option>
|
||
<option value="gemini" ?selected=${this.selectedProvider === 'gemini'}>Google Gemini</option>
|
||
</select>
|
||
<input
|
||
type="password"
|
||
class="api-input"
|
||
placeholder=${this.selectedProvider === 'openai' ? "Enter your OpenAI API key" : "Enter your Gemini API key"}
|
||
.value=${this.apiKey || ''}
|
||
@input=${this.handleInput}
|
||
@keypress=${this.handleKeyPress}
|
||
@paste=${this.handlePaste}
|
||
@focus=${() => (this.errorMessage = '')}
|
||
?disabled=${this.isLoading}
|
||
autocomplete="off"
|
||
spellcheck="false"
|
||
tabindex="0"
|
||
/>
|
||
|
||
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled} tabindex="0">
|
||
${this.isLoading ? 'Validating...' : 'Confirm'}
|
||
</button>
|
||
|
||
<div class="or-text">or</div>
|
||
|
||
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's API Key</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
}
|
||
|
||
customElements.define('apikey-header', ApiKeyHeader);
|