glass/src/app/ApiKeyHeader.js

552 lines
17 KiB
JavaScript

import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.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 },
providers: { type: Object, state: true },
}
//////// after_modelStateService ////////
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: 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 {
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;
}
.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 {
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 {
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.dragState = null
this.wasJustDragged = false
this.isLoading = false
this.errorMessage = ""
//////// after_modelStateService ////////
this.llmApiKey = "";
this.sttApiKey = "";
this.llmProvider = "openai";
this.sttProvider = "openai";
this.providers = { llm: [], stt: [] }; // 초기화
this.loadProviderConfig();
//////// after_modelStateService ////////
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)
}
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');
const config = await ipcRenderer.invoke('model:get-provider-config');
const llmProviders = [];
const sttProviders = [];
for (const id in config) {
// 'openai-glass' 같은 가상 Provider는 UI에 표시하지 않음
if (id.includes('-glass')) continue;
if (config[id].llmModels.length > 0) {
llmProviders.push({ id, name: config[id].name });
}
if (config[id].sttModels.length > 0) {
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;
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()
}
}
//////// after_modelStateService ////////
async handleSubmit() {
console.log('[ApiKeyHeader] handleSubmit: Submitting API keys...');
if (this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim()) {
this.errorMessage = "Please enter keys for both LLM and STT.";
return;
}
this.isLoading = true;
this.errorMessage = "";
this.requestUpdate();
const { ipcRenderer } = window.require('electron');
console.log('[ApiKeyHeader] handleSubmit: Validating LLM key...');
const llmValidation = ipcRenderer.invoke('model:validate-key', { provider: this.llmProvider, key: this.llmApiKey.trim() });
const sttValidation = ipcRenderer.invoke('model:validate-key', { provider: this.sttProvider, key: this.sttApiKey.trim() });
const [llmResult, sttResult] = await Promise.all([llmValidation, sttValidation]);
if (llmResult.success && sttResult.success) {
console.log('[ApiKeyHeader] handleSubmit: Both LLM and STT keys are valid.');
this.startSlideOutAnimation();
} else {
console.log('[ApiKeyHeader] handleSubmit: Validation failed.');
let errorParts = [];
if (!llmResult.success) errorParts.push(`LLM Key: ${llmResult.error || 'Invalid'}`);
if (!sttResult.success) errorParts.push(`STT Key: ${sttResult.error || 'Invalid'}`);
this.errorMessage = errorParts.join(' | ');
}
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()
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")
}
}
//////// after_modelStateService ////////
handleAnimationEnd(e) {
if (e.target !== this || !this.classList.contains('sliding-out')) return;
this.classList.remove("sliding-out");
this.classList.add("hidden");
window.require('electron').ipcRenderer.invoke('get-current-user').then(userState => {
console.log('[ApiKeyHeader] handleAnimationEnd: User state updated:', userState);
this.stateUpdateCallback?.(userState);
});
}
//////// after_modelStateService ////////
connectedCallback() {
super.connectedCallback()
this.addEventListener("animationend", this.handleAnimationEnd)
}
disconnectedCallback() {
super.disconnectedCallback()
this.removeEventListener("animationend", this.handleAnimationEnd)
}
render() {
const isButtonDisabled = this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim();
return html`
<div class="container" @mousedown=${this.handleMouseDown}>
<h1 class="title">Enter Your API Keys</h1>
<div class="providers-container">
<div class="provider-column">
<div class="provider-label"></div>
<select class="provider-select" .value=${this.llmProvider} @change=${e => this.llmProvider = e.target.value} ?disabled=${this.isLoading}>
${this.providers.llm.map(p => html`<option value=${p.id}>${p.name}</option>`)}
</select>
<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"></div>
<select class="provider-select" .value=${this.sttProvider} @change=${e => this.sttProvider = e.target.value} ?disabled=${this.isLoading}>
${this.providers.stt.map(p => html`<option value=${p.id}>${p.name}</option>`)}
</select>
<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.errorMessage}</div>
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled}>
${this.isLoading ? "Validating..." : "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)