add modelStateService for provider, model, apikey selection

This commit is contained in:
sanio 2025-07-08 06:09:14 +09:00
parent 516b1ae61e
commit 649c3b5c31
11 changed files with 1282 additions and 561 deletions

View File

@ -1,12 +1,17 @@
import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js" import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js"
export class ApiKeyHeader extends LitElement { export class ApiKeyHeader extends LitElement {
//////// after_modelStateService ////////
static properties = { static properties = {
apiKey: { type: String }, llmApiKey: { type: String },
sttApiKey: { type: String },
llmProvider: { type: String },
sttProvider: { type: String },
isLoading: { type: Boolean }, isLoading: { type: Boolean },
errorMessage: { type: String }, errorMessage: { type: String },
selectedProvider: { type: String }, providers: { type: Object, state: true },
} }
//////// after_modelStateService ////////
static styles = css` static styles = css`
:host { :host {
@ -45,7 +50,7 @@ export class ApiKeyHeader extends LitElement {
} }
.container { .container {
width: 285px; width: 350px;
min-height: 260px; min-height: 260px;
padding: 18px 20px; padding: 18px 20px;
background: rgba(0, 0, 0, 0.3); background: rgba(0, 0, 0, 0.3);
@ -153,28 +158,22 @@ export class ApiKeyHeader extends LitElement {
outline: none; outline: none;
} }
.provider-select { .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%; width: 100%;
height: 34px; height: 34px;
text-align: center;
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
border-radius: 10px; border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid rgba(255, 255, 255, 0.2);
padding: 0 10px; padding: 0 10px;
color: white; color: white;
font-size: 12px; font-size: 12px;
font-weight: 400;
margin-bottom: 6px; 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 option { background: #1a1a1a; color: white; }
.provider-select:hover { .provider-select:hover {
background-color: rgba(255, 255, 255, 0.15); background-color: rgba(255, 255, 255, 0.15);
@ -187,11 +186,6 @@ export class ApiKeyHeader extends LitElement {
border-color: rgba(255, 255, 255, 0.4); border-color: rgba(255, 255, 255, 0.4);
} }
.provider-select option {
background: #1a1a1a;
color: white;
padding: 5px;
}
.action-button { .action-button {
width: 100%; width: 100%;
@ -240,14 +234,6 @@ export class ApiKeyHeader extends LitElement {
margin: 10px 0; 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;
}
/* ────────────────[ GLASS BYPASS ]─────────────── */ /* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) .container, :host-context(body.has-glass) .container,
@ -278,11 +264,16 @@ export class ApiKeyHeader extends LitElement {
super() super()
this.dragState = null this.dragState = null
this.wasJustDragged = false this.wasJustDragged = false
this.apiKey = ""
this.isLoading = false this.isLoading = false
this.errorMessage = "" this.errorMessage = ""
this.validatedApiKey = null //////// after_modelStateService ////////
this.selectedProvider = "openai" 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.handleMouseMove = this.handleMouseMove.bind(this)
this.handleMouseUp = this.handleMouseUp.bind(this) this.handleMouseUp = this.handleMouseUp.bind(this)
@ -303,6 +294,35 @@ export class ApiKeyHeader extends LitElement {
this.requestUpdate() 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) { async handleMouseDown(e) {
if (e.target.tagName === "INPUT" || e.target.tagName === "BUTTON" || e.target.tagName === "SELECT") { if (e.target.tagName === "INPUT" || e.target.tagName === "BUTTON" || e.target.tagName === "SELECT") {
return return
@ -409,144 +429,45 @@ export class ApiKeyHeader extends LitElement {
} }
} }
//////// after_modelStateService ////////
async handleSubmit() { async handleSubmit() {
if (this.wasJustDragged || this.isLoading || !this.apiKey.trim()) { console.log('[ApiKeyHeader] handleSubmit: Submitting API keys...');
console.log("Submit blocked:", { if (this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim()) {
wasJustDragged: this.wasJustDragged, this.errorMessage = "Please enter keys for both LLM and STT.";
isLoading: this.isLoading, return;
hasApiKey: !!this.apiKey.trim(),
})
return
} }
console.log("Starting API key validation...") this.isLoading = true;
this.isLoading = true this.errorMessage = "";
this.errorMessage = "" this.requestUpdate();
this.requestUpdate()
const apiKey = this.apiKey.trim() const { ipcRenderer } = window.require('electron');
const isValid = false
try {
const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider)
if (isValid) { console.log('[ApiKeyHeader] handleSubmit: Validating LLM key...');
console.log("API key valid - starting slide out animation") const llmValidation = ipcRenderer.invoke('model:validate-key', { provider: this.llmProvider, key: this.llmApiKey.trim() });
this.startSlideOutAnimation() const sttValidation = ipcRenderer.invoke('model:validate-key', { provider: this.sttProvider, key: this.sttApiKey.trim() });
this.validatedApiKey = this.apiKey.trim()
this.validatedProvider = this.selectedProvider 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 { } else {
this.errorMessage = "Invalid API key - please check and try again" console.log('[ApiKeyHeader] handleSubmit: Validation failed.');
console.log("API key validation failed") let errorParts = [];
} if (!llmResult.success) errorParts.push(`LLM Key: ${llmResult.error || 'Invalid'}`);
} catch (error) { if (!sttResult.success) errorParts.push(`STT Key: ${sttResult.error || 'Invalid'}`);
console.error("API key validation error:", error) this.errorMessage = errorParts.join(' | ');
this.errorMessage = "Validation error - please try again"
} finally {
this.isLoading = false
this.requestUpdate()
}
} }
async validateApiKey(apiKey, provider = "openai") { this.isLoading = false;
if (!apiKey || apiKey.length < 15) return false this.requestUpdate();
}
//////// after_modelStateService ////////
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
}
} else if (provider === "anthropic") {
// Anthropic API keys typically start with 'sk-ant-'
if (!apiKey.startsWith("sk-ant-") || !apiKey.match(/^[A-Za-z0-9_-]+$/)) return false
try {
console.log("Validating Anthropic API key...")
// Test the API key with a simple request
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-3-haiku-20240307",
max_tokens: 10,
messages: [{ role: "user", content: "Hi" }],
}),
})
if (response.ok || response.status === 400) {
// 400 is also acceptable as it means the API key is valid but request format might be wrong
console.log("Anthropic API key validation successful")
return true
}
console.log("Anthropic API key validation failed:", response.status)
return false
} catch (error) {
console.error("Anthropic API key validation network error:", error)
return apiKey.length >= 20 // Fallback
}
}
return false
}
startSlideOutAnimation() { startSlideOutAnimation() {
console.log('[ApiKeyHeader] startSlideOutAnimation: Starting slide out animation.');
this.classList.add("sliding-out") this.classList.add("sliding-out")
} }
@ -567,25 +488,18 @@ export class ApiKeyHeader extends LitElement {
} }
} }
//////// after_modelStateService ////////
handleAnimationEnd(e) { handleAnimationEnd(e) {
if (e.target !== this) return if (e.target !== this || !this.classList.contains('sliding-out')) return;
this.classList.remove("sliding-out");
if (this.classList.contains("sliding-out")) { this.classList.add("hidden");
this.classList.remove("sliding-out") window.require('electron').ipcRenderer.invoke('get-current-user').then(userState => {
this.classList.add("hidden") console.log('[ApiKeyHeader] handleAnimationEnd: User state updated:', userState);
this.stateUpdateCallback?.(userState);
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
}
}
} }
//////// after_modelStateService ////////
connectedCallback() { connectedCallback() {
super.connectedCallback() super.connectedCallback()
@ -598,64 +512,40 @@ export class ApiKeyHeader extends LitElement {
} }
render() { render() {
const isButtonDisabled = this.isLoading || !this.apiKey || !this.apiKey.trim() const isButtonDisabled = this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim();
console.log("Rendering with provider:", this.selectedProvider)
return html` return html`
<div class="container" @mousedown=${this.handleMouseDown}> <div class="container" @mousedown=${this.handleMouseDown}>
<button class="close-button" @click=${this.handleClose} title="Close application"> <h1 class="title">Enter Your API Keys</h1>
<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="providers-container">
<div class="error-message">${this.errorMessage}</div> <div class="provider-column">
<div class="provider-label">Select AI Provider:</div> <div class="provider-label"></div>
<select <select class="provider-select" .value=${this.llmProvider} @change=${e => this.llmProvider = e.target.value} ?disabled=${this.isLoading}>
class="provider-select" ${this.providers.llm.map(p => html`<option value=${p.id}>${p.name}</option>`)}
.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>
<option value="anthropic" ?selected=${this.selectedProvider === "anthropic"}>Anthropic</option>
</select> </select>
<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}>
type="password" </div>
class="api-input"
placeholder=${
this.selectedProvider === "openai"
? "Enter your OpenAI API key"
: this.selectedProvider === "gemini"
? "Enter your Gemini API key"
: "Enter your Anthropic 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"> <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"} ${this.isLoading ? "Validating..." : "Confirm"}
</button> </button>
<div class="or-text">or</div> <div class="or-text">or</div>
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's Key (Login)</button>
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's API Key</button>
</div> </div>
</div> `;
` }
}
} }
customElements.define("apikey-header", ApiKeyHeader) customElements.define("apikey-header", ApiKeyHeader)

View File

@ -15,7 +15,11 @@ class HeaderTransitionManager {
* @param {'apikey'|'main'|'permission'} type * @param {'apikey'|'main'|'permission'} type
*/ */
this.ensureHeader = (type) => { this.ensureHeader = (type) => {
if (this.currentHeaderType === type) return; console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
if (this.currentHeaderType === type) {
console.log('[HeaderController] ensureHeader: Header of type:', type, 'already exists.');
return;
}
this.headerContainer.innerHTML = ''; this.headerContainer.innerHTML = '';
@ -26,6 +30,7 @@ class HeaderTransitionManager {
// Create new header element // Create new header element
if (type === 'apikey') { if (type === 'apikey') {
this.apiKeyHeader = document.createElement('apikey-header'); this.apiKeyHeader = document.createElement('apikey-header');
this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
this.headerContainer.appendChild(this.apiKeyHeader); this.headerContainer.appendChild(this.apiKeyHeader);
} else if (type === 'permission') { } else if (type === 'permission') {
this.permissionHeader = document.createElement('permission-setup'); this.permissionHeader = document.createElement('permission-setup');
@ -60,6 +65,11 @@ class HeaderTransitionManager {
this.apiKeyHeader.isLoading = false; this.apiKeyHeader.isLoading = false;
} }
}); });
ipcRenderer.on('force-show-apikey-header', async () => {
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
await this._resizeForApiKey();
this.ensureHeader('apikey');
});
} }
} }
@ -83,26 +93,30 @@ class HeaderTransitionManager {
} }
} }
async handleStateUpdate(userState) {
const { isLoggedIn, hasApiKey } = userState;
//////// after_modelStateService ////////
async handleStateUpdate(userState) {
const { ipcRenderer } = window.require('electron');
const isConfigured = await ipcRenderer.invoke('model:are-providers-configured');
if (isConfigured) {
const { isLoggedIn } = userState;
if (isLoggedIn) { if (isLoggedIn) {
// Firebase user: Check permissions, then show Main or Permission header
const permissionResult = await this.checkPermissions(); const permissionResult = await this.checkPermissions();
if (permissionResult.success) { if (permissionResult.success) {
this.transitionToMainHeader(); this.transitionToMainHeader();
} else { } else {
this.transitionToPermissionHeader(); this.transitionToPermissionHeader();
} }
} else if (hasApiKey) {
// API Key only user: Skip permission check, go directly to Main
this.transitionToMainHeader();
} else { } else {
// No auth at all this.transitionToMainHeader();
}
} else {
await this._resizeForApiKey(); await this._resizeForApiKey();
this.ensureHeader('apikey'); this.ensureHeader('apikey');
} }
} }
//////// after_modelStateService ////////
async transitionToPermissionHeader() { async transitionToPermissionHeader() {
// Prevent duplicate transitions // Prevent duplicate transitions
@ -159,7 +173,7 @@ class HeaderTransitionManager {
if (!window.require) return; if (!window.require) return;
return window return window
.require('electron') .require('electron')
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 300 }) .ipcRenderer.invoke('resize-header-window', { width: 350, height: 300 })
.catch(() => {}); .catch(() => {});
} }

View File

@ -1,68 +1,121 @@
const providers = { // factory.js
openai: require("./providers/openai"),
gemini: require("./providers/gemini"),
anthropic: require("./providers/anthropic"),
// 추가 provider는 여기에 등록
}
/** /**
* Creates an STT session based on provider * @typedef {object} ModelOption
* @param {string} provider - Provider name ('openai', 'gemini', etc.) * @property {string} id
* @param {object} opts - Configuration options (apiKey, language, callbacks, etc.) * @property {string} name
* @returns {Promise<object>} STT session object with sendRealtimeInput and close methods
*/ */
/**
* @typedef {object} Provider
* @property {string} name
* @property {() => any} handler
* @property {ModelOption[]} llmModels
* @property {ModelOption[]} sttModels
*/
/**
* @type {Object.<string, Provider>}
*/
const PROVIDERS = {
'openai': {
name: 'OpenAI',
handler: () => require("./providers/openai"),
llmModels: [
{ id: 'gpt-4.1', name: 'GPT-4.1' },
],
sttModels: [
{ id: 'gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' }
],
},
'openai-glass': {
name: 'OpenAI (Glass)',
handler: () => require("./providers/openai"),
llmModels: [
{ id: 'gpt-4.1-glass', name: 'GPT-4.1 (glass)' },
],
sttModels: [
{ id: 'gpt-4o-mini-transcribe-glass', name: 'GPT-4o Mini Transcribe (glass)' }
],
},
'gemini': {
name: 'Gemini',
handler: () => require("./providers/gemini"),
llmModels: [
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
],
sttModels: [
{ id: 'gemini-live-2.5-flash-preview', name: 'Gemini Live 2.5 Flash' }
],
},
'anthropic': {
name: 'Anthropic',
handler: () => require("./providers/anthropic"),
llmModels: [
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
],
sttModels: [],
},
};
function sanitizeModelId(model) {
return (typeof model === 'string') ? model.replace(/-glass$/, '') : model;
}
function createSTT(provider, opts) { function createSTT(provider, opts) {
if (!providers[provider]?.createSTT) { if (provider === 'openai-glass') provider = 'openai';
throw new Error(`STT not supported for provider: ${provider}`)
const handler = PROVIDERS[provider]?.handler();
if (!handler?.createSTT) {
throw new Error(`STT not supported for provider: ${provider}`);
} }
return providers[provider].createSTT(opts) if (opts && opts.model) {
opts = { ...opts, model: sanitizeModelId(opts.model) };
}
return handler.createSTT(opts);
} }
/**
* Creates an LLM instance based on provider
* @param {string} provider - Provider name ('openai', 'gemini', etc.)
* @param {object} opts - Configuration options (apiKey, model, temperature, etc.)
* @returns {object} LLM instance with generateContent method
*/
function createLLM(provider, opts) { function createLLM(provider, opts) {
if (!providers[provider]?.createLLM) { if (provider === 'openai-glass') provider = 'openai';
throw new Error(`LLM not supported for provider: ${provider}`)
const handler = PROVIDERS[provider]?.handler();
if (!handler?.createLLM) {
throw new Error(`LLM not supported for provider: ${provider}`);
} }
return providers[provider].createLLM(opts) if (opts && opts.model) {
opts = { ...opts, model: sanitizeModelId(opts.model) };
}
return handler.createLLM(opts);
} }
/**
* Creates a streaming LLM instance based on provider
* @param {string} provider - Provider name ('openai', 'gemini', etc.)
* @param {object} opts - Configuration options (apiKey, model, temperature, etc.)
* @returns {object} Streaming LLM instance
*/
function createStreamingLLM(provider, opts) { function createStreamingLLM(provider, opts) {
if (!providers[provider]?.createStreamingLLM) { if (provider === 'openai-glass') provider = 'openai';
throw new Error(`Streaming LLM not supported for provider: ${provider}`)
const handler = PROVIDERS[provider]?.handler();
if (!handler?.createStreamingLLM) {
throw new Error(`Streaming LLM not supported for provider: ${provider}`);
} }
return providers[provider].createStreamingLLM(opts) if (opts && opts.model) {
opts = { ...opts, model: sanitizeModelId(opts.model) };
}
return handler.createStreamingLLM(opts);
} }
/**
* Gets list of available providers
* @returns {object} Object with stt and llm arrays
*/
function getAvailableProviders() { function getAvailableProviders() {
const sttProviders = [] const stt = [];
const llmProviders = [] const llm = [];
for (const [id, provider] of Object.entries(PROVIDERS)) {
for (const [name, provider] of Object.entries(providers)) { if (provider.sttModels.length > 0) stt.push(id);
if (provider.createSTT) sttProviders.push(name) if (provider.llmModels.length > 0) llm.push(id);
if (provider.createLLM) llmProviders.push(name)
} }
return { stt: [...new Set(stt)], llm: [...new Set(llm)] };
return { stt: sttProviders, llm: llmProviders }
} }
module.exports = { module.exports = {
PROVIDERS,
createSTT, createSTT,
createLLM, createLLM,
createStreamingLLM, createStreamingLLM,
getAvailableProviders, getAvailableProviders,
} };

View File

@ -36,7 +36,6 @@ class AuthService {
this.currentUserId = 'default_user'; this.currentUserId = 'default_user';
this.currentUserMode = 'local'; // 'local' or 'firebase' this.currentUserMode = 'local'; // 'local' or 'firebase'
this.currentUser = null; this.currentUser = null;
this.hasApiKey = false; // Add a flag for API key status
this.isInitialized = false; this.isInitialized = false;
} }
@ -53,20 +52,18 @@ class AuthService {
this.currentUser = user; this.currentUser = user;
this.currentUserId = user.uid; this.currentUserId = user.uid;
this.currentUserMode = 'firebase'; this.currentUserMode = 'firebase';
this.hasApiKey = false; // Optimistically assume no key yet
// Broadcast immediately to make UI feel responsive
this.broadcastUserState();
// Start background task to fetch and save virtual key // Start background task to fetch and save virtual key
(async () => { (async () => {
try { try {
const idToken = await user.getIdToken(true); const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken); const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
await userRepository.saveApiKey(virtualKey, user.uid, 'openai');
console.log(`[AuthService] BG: Virtual key for ${user.email} has been saved.`); if (global.modelStateService) {
// Now update the key status, which will trigger another broadcast global.modelStateService.setFirebaseVirtualKey(virtualKey);
await this.updateApiKeyStatus(); }
console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
} catch (error) { } catch (error) {
console.error('[AuthService] BG: Failed to fetch or save virtual key:', error); console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
} }
@ -74,23 +71,20 @@ class AuthService {
} else { } else {
// User signed OUT // User signed OUT
console.log(`[AuthService] Firebase user signed out.`); console.log(`[AuthService] No Firebase user.`);
if (previousUser) { if (previousUser) {
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`); console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
await userRepository.saveApiKey(null, previousUser.uid); if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(null);
}
} }
this.currentUser = null; this.currentUser = null;
this.currentUserId = 'default_user'; this.currentUserId = 'default_user';
this.currentUserMode = 'local'; this.currentUserMode = 'local';
// Update API key status (e.g., if a local key for default_user exists)
// This will also broadcast the final logged-out state.
await this.updateApiKeyStatus();
} }
this.broadcastUserState();
}); });
// Check for initial API key state
this.updateApiKeyStatus();
this.isInitialized = true; this.isInitialized = true;
console.log('[AuthService] Initialized and attached to Firebase Auth state.'); console.log('[AuthService] Initialized and attached to Firebase Auth state.');
} }
@ -129,23 +123,6 @@ class AuthService {
}); });
} }
/**
* Updates the internal API key status from the repository and broadcasts if changed.
*/
async updateApiKeyStatus() {
try {
const user = await userRepository.getById(this.currentUserId);
const newStatus = !!(user && user.api_key);
if (this.hasApiKey !== newStatus) {
console.log(`[AuthService] API key status changed to: ${newStatus}`);
this.hasApiKey = newStatus;
this.broadcastUserState();
}
} catch (error) {
console.error('[AuthService] Error checking API key status:', error);
this.hasApiKey = false;
}
}
getCurrentUserId() { getCurrentUserId() {
return this.currentUserId; return this.currentUserId;
@ -161,7 +138,9 @@ class AuthService {
displayName: this.currentUser.displayName, displayName: this.currentUser.displayName,
mode: 'firebase', mode: 'firebase',
isLoggedIn: true, isLoggedIn: true,
hasApiKey: this.hasApiKey // Always true for firebase users, but good practice //////// before_modelStateService ////////
// hasApiKey: this.hasApiKey // Always true for firebase users, but good practice
//////// before_modelStateService ////////
}; };
} }
return { return {
@ -170,7 +149,9 @@ class AuthService {
displayName: 'Default User', displayName: 'Default User',
mode: 'local', mode: 'local',
isLoggedIn: false, isLoggedIn: false,
hasApiKey: this.hasApiKey //////// before_modelStateService ////////
// hasApiKey: this.hasApiKey
//////// before_modelStateService ////////
}; };
} }
} }

View File

@ -0,0 +1,324 @@
const Store = require('electron-store');
const fetch = require('node-fetch');
const { ipcMain, webContents } = require('electron');
const { PROVIDERS } = require('../ai/factory');
class ModelStateService {
constructor(authService) {
this.authService = authService;
this.store = new Store({ name: 'pickle-glass-model-state' });
this.state = {};
}
initialize() {
this._loadStateForCurrentUser();
this.setupIpcHandlers();
console.log('[ModelStateService] Initialized.');
}
_logCurrentSelection() {
const llmModel = this.state.selectedModels.llm;
const sttModel = this.state.selectedModels.stt;
const llmProvider = this.getProviderForModel('llm', llmModel) || 'None';
const sttProvider = this.getProviderForModel('stt', sttModel) || 'None';
console.log(`[ModelStateService] 🌟 Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
}
_autoSelectAvailableModels() {
console.log('[ModelStateService] Running auto-selection for models...');
const types = ['llm', 'stt'];
types.forEach(type => {
const currentModelId = this.state.selectedModels[type];
let isCurrentModelValid = false;
if (currentModelId) {
const provider = this.getProviderForModel(type, currentModelId);
if (provider && this.getApiKey(provider)) {
isCurrentModelValid = true;
}
}
if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`);
const availableModels = this.getAvailableModels(type);
if (availableModels.length > 0) {
this.state.selectedModels[type] = availableModels[0].id;
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${availableModels[0].id}`);
} else {
this.state.selectedModels[type] = null;
}
}
});
}
_loadStateForCurrentUser() {
const userId = this.authService.getCurrentUserId();
const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => {
acc[key] = null;
return acc;
}, {});
const defaultState = {
apiKeys: initialApiKeys,
selectedModels: { llm: null, stt: null },
};
this.state = this.store.get(`users.${userId}`, defaultState);
console.log(`[ModelStateService] State loaded for user: ${userId}`);
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
}
_saveState() {
const userId = this.authService.getCurrentUserId();
this.store.set(`users.${userId}`, this.state);
console.log(`[ModelStateService] State saved for user: ${userId}`);
this._logCurrentSelection();
}
async validateApiKey(provider, key) {
if (!key || key.trim() === '') {
return { success: false, error: 'API key cannot be empty.' };
}
let validationUrl, headers;
const body = undefined;
switch (provider) {
case 'openai':
validationUrl = 'https://api.openai.com/v1/models';
headers = { 'Authorization': `Bearer ${key}` };
break;
case 'gemini':
validationUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`;
headers = {};
break;
case 'anthropic': {
if (!key.startsWith('sk-ant-')) {
throw new Error('Invalid Anthropic key format.');
}
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": key,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-3-haiku-20240307",
max_tokens: 1,
messages: [{ role: "user", content: "Hi" }],
}),
});
if (!response.ok && response.status !== 400) {
const errorData = await response.json().catch(() => ({}));
return { success: false, error: errorData.error?.message || `Validation failed with status: ${response.status}` };
}
console.log(`[ModelStateService] API key for ${provider} is valid.`);
this.setApiKey(provider, key);
return { success: true };
}
default:
return { success: false, error: 'Unknown provider.' };
}
try {
const response = await fetch(validationUrl, { headers, body });
if (response.ok) {
console.log(`[ModelStateService] API key for ${provider} is valid.`);
this.setApiKey(provider, key);
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
const message = errorData.error?.message || `Validation failed with status: ${response.status}`;
console.log(`[ModelStateService] API key for ${provider} is invalid: ${message}`);
return { success: false, error: message };
}
} catch (error) {
console.error(`[ModelStateService] Network error during ${provider} key validation:`, error);
return { success: false, error: 'A network error occurred during validation.' };
}
}
setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`);
this.state.apiKeys['openai-glass'] = virtualKey;
const llmModels = PROVIDERS['openai-glass']?.llmModels;
const sttModels = PROVIDERS['openai-glass']?.sttModels;
if (!this.state.selectedModels.llm && llmModels?.length > 0) {
this.state.selectedModels.llm = llmModels[0].id;
}
if (!this.state.selectedModels.stt && sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id;
}
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
}
setApiKey(provider, key) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = key;
const llmModels = PROVIDERS[provider]?.llmModels;
const sttModels = PROVIDERS[provider]?.sttModels;
if (!this.state.selectedModels.llm && llmModels?.length > 0) {
this.state.selectedModels.llm = llmModels[0].id;
}
if (!this.state.selectedModels.stt && sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id;
}
this._saveState();
this._logCurrentSelection();
return true;
}
return false;
}
getApiKey(provider) {
return this.state.apiKeys[provider] || null;
}
getAllApiKeys() {
const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys;
return displayKeys;
}
removeApiKey(provider) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = null;
const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm);
if (llmProvider === provider) this.state.selectedModels.llm = null;
const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt);
if (sttProvider === provider) this.state.selectedModels.stt = null;
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
return true;
}
return false;
}
getProviderForModel(type, modelId) {
if (!modelId) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models.some(m => m.id === modelId)) {
return providerId;
}
}
return null;
}
getCurrentProvider(type) {
const selectedModel = this.state.selectedModels[type];
return this.getProviderForModel(type, selectedModel);
}
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
}
areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true;
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => key && PROVIDERS[provider]?.llmModels.length > 0);
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => key && PROVIDERS[provider]?.sttModels.length > 0);
return hasLlmKey && hasSttKey;
}
getAvailableModels(type) {
const available = [];
const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
if (key && PROVIDERS[providerId]?.[modelList]) {
available.push(...PROVIDERS[providerId][modelList]);
}
});
return [...new Map(available.map(item => [item.id, item])).values()];
}
getSelectedModels() {
return this.state.selectedModels;
}
setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(type, modelId);
if (provider && this.state.apiKeys[provider]) {
this.state.selectedModels[type] = modelId;
this._saveState();
return true;
}
return false;
}
/**
*
* @param {('llm' | 'stt')} type
* @returns {{provider: string, model: string, apiKey: string} | null}
*/
getCurrentModelInfo(type) {
this._logCurrentSelection();
const model = this.state.selectedModels[type];
if (!model) {
return null;
}
const provider = this.getProviderForModel(type, model);
if (!provider) {
return null;
}
const apiKey = this.getApiKey(provider);
return { provider, model, apiKey };
}
setupIpcHandlers() {
ipcMain.handle('model:validate-key', (e, { provider, key }) => this.validateApiKey(provider, key));
ipcMain.handle('model:get-all-keys', () => this.getAllApiKeys());
ipcMain.handle('model:set-api-key', (e, { provider, key }) => this.setApiKey(provider, key));
ipcMain.handle('model:remove-api-key', (e, { provider }) => {
const success = this.removeApiKey(provider);
if (success) {
const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) {
webContents.getAllWebContents().forEach(wc => {
wc.send('force-show-apikey-header');
});
}
}
return success;
});
ipcMain.handle('model:get-selected-models', () => this.getSelectedModels());
ipcMain.handle('model:set-selected-model', (e, { type, modelId }) => this.setSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', (e, { type }) => this.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', () => this.areProvidersConfigured());
ipcMain.handle('model:get-current-model-info', (e, { type }) => this.getCurrentModelInfo(type));
ipcMain.handle('model:get-provider-config', () => {
const serializableProviders = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
serializableProviders[key] = rest;
}
return serializableProviders;
});
}
}
module.exports = ModelStateService;

View File

@ -971,13 +971,15 @@ function setupIpcHandlers(movementManager) {
console.log('[WindowManager] Received request to log out.'); console.log('[WindowManager] Received request to log out.');
await authService.signOut(); await authService.signOut();
await setApiKey(null); //////// before_modelStateService ////////
// await setApiKey(null);
windowPool.forEach(win => { // windowPool.forEach(win => {
if (win && !win.isDestroyed()) { // if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed'); // win.webContents.send('api-key-removed');
} // }
}); // });
//////// before_modelStateService ////////
}); });
ipcMain.handle('check-system-permissions', async () => { ipcMain.handle('check-system-permissions', async () => {
@ -1112,95 +1114,150 @@ function setupIpcHandlers(movementManager) {
} }
async function setApiKey(apiKey, provider = 'openai') { //////// before_modelStateService ////////
console.log('[WindowManager] Persisting API key and provider to DB'); // async function setApiKey(apiKey, provider = 'openai') {
// console.log('[WindowManager] Persisting API key and provider to DB');
try { // try {
await userRepository.saveApiKey(apiKey, authService.getCurrentUserId(), provider); // await userRepository.saveApiKey(apiKey, authService.getCurrentUserId(), provider);
console.log('[WindowManager] API key and provider saved to SQLite'); // console.log('[WindowManager] API key and provider saved to SQLite');
// Notify authService that the key status may have changed // // Notify authService that the key status may have changed
await authService.updateApiKeyStatus(); // await authService.updateApiKeyStatus();
} catch (err) { // } catch (err) {
console.error('[WindowManager] Failed to save API key to SQLite:', err); // console.error('[WindowManager] Failed to save API key to SQLite:', err);
} // }
windowPool.forEach(win => { // windowPool.forEach(win => {
if (win && !win.isDestroyed()) { // if (win && !win.isDestroyed()) {
const js = apiKey ? ` // const js = apiKey ? `
localStorage.setItem('openai_api_key', ${JSON.stringify(apiKey)}); // localStorage.setItem('openai_api_key', ${JSON.stringify(apiKey)});
localStorage.setItem('ai_provider', ${JSON.stringify(provider)}); // localStorage.setItem('ai_provider', ${JSON.stringify(provider)});
` : ` // ` : `
localStorage.removeItem('openai_api_key'); // localStorage.removeItem('openai_api_key');
localStorage.removeItem('ai_provider'); // localStorage.removeItem('ai_provider');
`; // `;
win.webContents.executeJavaScript(js).catch(() => {}); // win.webContents.executeJavaScript(js).catch(() => {});
} // }
}); // });
} // }
// async function getStoredApiKey() {
// const userId = authService.getCurrentUserId();
// if (!userId) return null;
// const user = await userRepository.getById(userId);
// return user?.api_key || null;
// }
// async function getStoredProvider() {
// const userId = authService.getCurrentUserId();
// if (!userId) return 'openai';
// const user = await userRepository.getById(userId);
// return user?.provider || 'openai';
// }
// function setupApiKeyIPC() {
// const { ipcMain } = require('electron');
// // Both handlers now do the same thing: fetch the key from the source of truth.
// ipcMain.handle('get-stored-api-key', getStoredApiKey);
// ipcMain.handle('api-key-validated', async (event, data) => {
// console.log('[WindowManager] API key validation completed, saving...');
// // Support both old format (string) and new format (object)
// const apiKey = typeof data === 'string' ? data : data.apiKey;
// const provider = typeof data === 'string' ? 'openai' : (data.provider || 'openai');
// await setApiKey(apiKey, provider);
// windowPool.forEach((win, name) => {
// if (win && !win.isDestroyed()) {
// win.webContents.send('api-key-validated', { apiKey, provider });
// }
// });
// return { success: true };
// });
// ipcMain.handle('remove-api-key', async () => {
// console.log('[WindowManager] API key removal requested');
// await setApiKey(null);
// windowPool.forEach((win, name) => {
// if (win && !win.isDestroyed()) {
// win.webContents.send('api-key-removed');
// }
// });
// const settingsWindow = windowPool.get('settings');
// if (settingsWindow && settingsWindow.isVisible()) {
// settingsWindow.hide();
// console.log('[WindowManager] Settings window hidden after clearing API key.');
// }
// return { success: true };
// });
// ipcMain.handle('get-ai-provider', getStoredProvider);
// console.log('[WindowManager] API key related IPC handlers registered (SQLite-backed)');
// }
//////// before_modelStateService ////////
//////// after_modelStateService ////////
async function getStoredApiKey() { async function getStoredApiKey() {
const userId = authService.getCurrentUserId(); if (global.modelStateService) {
if (!userId) return null; const provider = await getStoredProvider();
const user = await userRepository.getById(userId); return global.modelStateService.getApiKey(provider);
return user?.api_key || null; }
return null; // Fallback
} }
async function getStoredProvider() { async function getStoredProvider() {
const userId = authService.getCurrentUserId(); if (global.modelStateService) {
if (!userId) return 'openai'; return global.modelStateService.getCurrentProvider('llm');
const user = await userRepository.getById(userId); }
return user?.provider || 'openai'; return 'openai'; // Fallback
}
/**
* 렌더러에서 요청한 타입('llm' 또는 'stt') 대한 모델 정보를 반환합니다.
* @param {IpcMainInvokeEvent} event - 일렉트론 IPC 이벤트 객체
* @param {{type: 'llm' | 'stt'}} { type } - 요청할 모델 타입
*/
async function getCurrentModelInfo(event, { type }) {
if (global.modelStateService && (type === 'llm' || type === 'stt')) {
return global.modelStateService.getCurrentModelInfo(type);
}
return null; // 서비스가 없거나 유효하지 않은 타입일 경우 null 반환
} }
function setupApiKeyIPC() { function setupApiKeyIPC() {
const { ipcMain } = require('electron'); const { ipcMain } = require('electron');
// Both handlers now do the same thing: fetch the key from the source of truth.
ipcMain.handle('get-stored-api-key', getStoredApiKey); ipcMain.handle('get-stored-api-key', getStoredApiKey);
ipcMain.handle('get-ai-provider', getStoredProvider);
ipcMain.handle('get-current-model-info', getCurrentModelInfo);
ipcMain.handle('api-key-validated', async (event, data) => { ipcMain.handle('api-key-validated', async (event, data) => {
console.log('[WindowManager] API key validation completed, saving...'); console.warn("[DEPRECATED] 'api-key-validated' IPC was called. This logic is now handled by 'model:validate-key'.");
// Support both old format (string) and new format (object)
const apiKey = typeof data === 'string' ? data : data.apiKey;
const provider = typeof data === 'string' ? 'openai' : (data.provider || 'openai');
await setApiKey(apiKey, provider);
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-validated', { apiKey, provider });
}
});
return { success: true }; return { success: true };
}); });
ipcMain.handle('remove-api-key', async () => { ipcMain.handle('remove-api-key', async () => {
console.log('[WindowManager] API key removal requested'); console.warn("[DEPRECATED] 'remove-api-key' IPC was called. This is now handled by 'model:remove-api-key'.");
await setApiKey(null);
windowPool.forEach((win, name) => {
if (win && !win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
const settingsWindow = windowPool.get('settings');
if (settingsWindow && settingsWindow.isVisible()) {
settingsWindow.hide();
console.log('[WindowManager] Settings window hidden after clearing API key.');
}
return { success: true }; return { success: true };
}); });
ipcMain.handle('get-ai-provider', getStoredProvider); console.log('[WindowManager] API key related IPC handlers have been updated for ModelStateService.');
console.log('[WindowManager] API key related IPC handlers registered (SQLite-backed)');
} }
//////// after_modelStateService ////////
function getDefaultKeybinds() { function getDefaultKeybinds() {
@ -1222,7 +1279,7 @@ function getDefaultKeybinds() {
} }
function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementManager) { function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementManager) {
console.log('Updating global shortcuts with:', keybinds); // console.log('Updating global shortcuts with:', keybinds);
// Unregister all existing shortcuts // Unregister all existing shortcuts
globalShortcut.unregisterAll(); globalShortcut.unregisterAll();
@ -1276,7 +1333,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
movementManager.moveStep(direction); movementManager.moveStep(direction);
} }
}); });
console.log(`Registered global shortcut: ${key} -> ${direction}`); // console.log(`Registered global shortcut: ${key} -> ${direction}`);
} catch (error) { } catch (error) {
console.error(`Failed to register ${key}:`, error); console.error(`Failed to register ${key}:`, error);
} }
@ -1316,7 +1373,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
} }
mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored); mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored);
}); });
console.log(`Registered toggleClickThrough: ${keybinds.toggleClickThrough}`); // console.log(`Registered toggleClickThrough: ${keybinds.toggleClickThrough}`);
} catch (error) { } catch (error) {
console.error(`Failed to register toggleClickThrough (${keybinds.toggleClickThrough}):`, error); console.error(`Failed to register toggleClickThrough (${keybinds.toggleClickThrough}):`, error);
} }
@ -1352,7 +1409,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
} }
} }
}); });
console.log(`Registered Ask shortcut (nextStep): ${keybinds.nextStep}`); // console.log(`Registered Ask shortcut (nextStep): ${keybinds.nextStep}`);
} catch (error) { } catch (error) {
console.error(`Failed to register Ask shortcut (${keybinds.nextStep}):`, error); console.error(`Failed to register Ask shortcut (${keybinds.nextStep}):`, error);
} }
@ -1370,7 +1427,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
} }
`); `);
}); });
console.log(`Registered manualScreenshot: ${keybinds.manualScreenshot}`); // console.log(`Registered manualScreenshot: ${keybinds.manualScreenshot}`);
} catch (error) { } catch (error) {
console.error(`Failed to register manualScreenshot (${keybinds.manualScreenshot}):`, error); console.error(`Failed to register manualScreenshot (${keybinds.manualScreenshot}):`, error);
} }
@ -1382,7 +1439,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
console.log('Previous response shortcut triggered'); console.log('Previous response shortcut triggered');
sendToRenderer('navigate-previous-response'); sendToRenderer('navigate-previous-response');
}); });
console.log(`Registered previousResponse: ${keybinds.previousResponse}`); // console.log(`Registered previousResponse: ${keybinds.previousResponse}`);
} catch (error) { } catch (error) {
console.error(`Failed to register previousResponse (${keybinds.previousResponse}):`, error); console.error(`Failed to register previousResponse (${keybinds.previousResponse}):`, error);
} }
@ -1394,7 +1451,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
console.log('Next response shortcut triggered'); console.log('Next response shortcut triggered');
sendToRenderer('navigate-next-response'); sendToRenderer('navigate-next-response');
}); });
console.log(`Registered nextResponse: ${keybinds.nextResponse}`); // console.log(`Registered nextResponse: ${keybinds.nextResponse}`);
} catch (error) { } catch (error) {
console.error(`Failed to register nextResponse (${keybinds.nextResponse}):`, error); console.error(`Failed to register nextResponse (${keybinds.nextResponse}):`, error);
} }
@ -1406,7 +1463,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
console.log('Scroll up shortcut triggered'); console.log('Scroll up shortcut triggered');
sendToRenderer('scroll-response-up'); sendToRenderer('scroll-response-up');
}); });
console.log(`Registered scrollUp: ${keybinds.scrollUp}`); // console.log(`Registered scrollUp: ${keybinds.scrollUp}`);
} catch (error) { } catch (error) {
console.error(`Failed to register scrollUp (${keybinds.scrollUp}):`, error); console.error(`Failed to register scrollUp (${keybinds.scrollUp}):`, error);
} }
@ -1418,7 +1475,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
console.log('Scroll down shortcut triggered'); console.log('Scroll down shortcut triggered');
sendToRenderer('scroll-response-down'); sendToRenderer('scroll-response-down');
}); });
console.log(`Registered scrollDown: ${keybinds.scrollDown}`); // console.log(`Registered scrollDown: ${keybinds.scrollDown}`);
} catch (error) { } catch (error) {
console.error(`Failed to register scrollDown (${keybinds.scrollDown}):`, error); console.error(`Failed to register scrollDown (${keybinds.scrollDown}):`, error);
} }
@ -1495,8 +1552,11 @@ module.exports = {
createWindows, createWindows,
windowPool, windowPool,
fixedYPosition, fixedYPosition,
setApiKey, //////// before_modelStateService ////////
// setApiKey,
//////// before_modelStateService ////////
getStoredApiKey, getStoredApiKey,
getStoredProvider, getStoredProvider,
getCurrentModelInfo,
captureScreenshot, captureScreenshot,
}; };

View File

@ -1,6 +1,6 @@
const { ipcMain, BrowserWindow } = require('electron'); const { ipcMain, BrowserWindow } = require('electron');
const { createStreamingLLM } = require('../../common/ai/factory'); const { createStreamingLLM } = require('../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, windowPool, captureScreenshot } = require('../../electron/windowManager'); const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../electron/windowManager');
const authService = require('../../common/services/authService'); const authService = require('../../common/services/authService');
const sessionRepository = require('../../common/repositories/session'); const sessionRepository = require('../../common/repositories/session');
const askRepository = require('./repositories'); const askRepository = require('./repositories');
@ -31,6 +31,12 @@ async function sendMessage(userPrompt) {
try { try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.');
}
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
const screenshotResult = await captureScreenshot({ quality: 'medium' }); const screenshotResult = await captureScreenshot({ quality: 'medium' });
const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null; const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
@ -39,10 +45,6 @@ async function sendMessage(userPrompt) {
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false); const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
const API_KEY = await getStoredApiKey();
if (!API_KEY) {
throw new Error('No API key found');
}
const messages = [ const messages = [
{ role: 'system', content: systemPrompt }, { role: 'system', content: systemPrompt },
@ -61,36 +63,13 @@ async function sendMessage(userPrompt) {
}); });
} }
const provider = await getStoredProvider(); const streamingLLM = createStreamingLLM(modelInfo.provider, {
const { isLoggedIn } = authService.getCurrentUser(); apiKey: modelInfo.apiKey,
model: modelInfo.model,
console.log(`[AskService] 🚀 Sending request to ${provider} AI...`);
// FIX: Proper model selection for each provider
let model;
switch (provider) {
case 'openai':
model = 'gpt-4o'; // Use a valid OpenAI model
break;
case 'gemini':
model = 'gemini-2.0-flash-exp'; // Use a valid Gemini model
break;
case 'anthropic':
model = 'claude-3-5-sonnet-20241022'; // Use a valid Claude model
break;
default:
model = 'gpt-4o'; // Default fallback
}
console.log(`[AskService] Using model: ${model} for provider: ${provider}`);
const streamingLLM = createStreamingLLM(provider, {
apiKey: API_KEY,
model: model,
temperature: 0.7, temperature: 0.7,
maxTokens: 2048, maxTokens: 2048,
usePortkey: provider === 'openai' && isLoggedIn, usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: isLoggedIn ? API_KEY : undefined portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
}); });
const response = await streamingLLM.streamChat(messages); const response = await streamingLLM.streamChat(messages);

View File

@ -1,7 +1,7 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createSTT } = require('../../../common/ai/factory'); const { createSTT } = require('../../../common/ai/factory');
const { getStoredApiKey, getStoredProvider } = require('../../../electron/windowManager'); const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager');
const COMPLETION_DEBOUNCE_MS = 2000; const COMPLETION_DEBOUNCE_MS = 2000;
@ -29,6 +29,8 @@ class SttService {
// Callbacks // Callbacks
this.onTranscriptionComplete = null; this.onTranscriptionComplete = null;
this.onStatusUpdate = null; this.onStatusUpdate = null;
this.modelInfo = null;
} }
setCallbacks({ onTranscriptionComplete, onStatusUpdate }) { setCallbacks({ onTranscriptionComplete, onStatusUpdate }) {
@ -36,32 +38,32 @@ class SttService {
this.onStatusUpdate = onStatusUpdate; this.onStatusUpdate = onStatusUpdate;
} }
async getApiKey() { // async getApiKey() {
const storedKey = await getStoredApiKey(); // const storedKey = await getStoredApiKey();
if (storedKey) { // if (storedKey) {
console.log('[SttService] Using stored API key'); // console.log('[SttService] Using stored API key');
return storedKey; // return storedKey;
} // }
const envKey = process.env.OPENAI_API_KEY; // const envKey = process.env.OPENAI_API_KEY;
if (envKey) { // if (envKey) {
console.log('[SttService] Using environment API key'); // console.log('[SttService] Using environment API key');
return envKey; // return envKey;
} // }
console.error('[SttService] No API key found in storage or environment'); // console.error('[SttService] No API key found in storage or environment');
return null; // return null;
} // }
async getAiProvider() { // async getAiProvider() {
try { // try {
const { ipcRenderer } = require('electron'); // const { ipcRenderer } = require('electron');
const provider = await ipcRenderer.invoke('get-ai-provider'); // const provider = await ipcRenderer.invoke('get-ai-provider');
return provider || 'openai'; // return provider || 'openai';
} catch (error) { // } catch (error) {
return getStoredProvider ? getStoredProvider() : 'openai'; // return getStoredProvider ? getStoredProvider() : 'openai';
} // }
} // }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { BrowserWindow.getAllWindows().forEach(win => {
@ -156,17 +158,25 @@ class SttService {
async initializeSttSessions(language = 'en') { async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
const API_KEY = await this.getApiKey(); // const API_KEY = await this.getApiKey();
if (!API_KEY) { // if (!API_KEY) {
throw new Error('No API key available'); // throw new Error('No API key available');
} // }
// const provider = await this.getAiProvider();
const provider = await this.getAiProvider(); const modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
const isGemini = provider === 'gemini'; if (!modelInfo || !modelInfo.apiKey) {
console.log(`[SttService] Initializing STT for provider: ${provider}`); throw new Error('AI model or API key is not configured.');
}
this.modelInfo = modelInfo;
console.log(`[SttService] Initializing STT for ${modelInfo.provider} using model ${modelInfo.model}`);
// const isGemini = modelInfo.provider === 'gemini';
// console.log(`[SttService] Initializing STT for provider: ${modelInfo.provider}`);
const handleMyMessage = message => { const handleMyMessage = message => {
if (isGemini) { if (this.modelInfo.provider === 'gemini') {
const text = message.serverContent?.inputTranscription?.text || ''; const text = message.serverContent?.inputTranscription?.text || '';
if (text && text.trim()) { if (text && text.trim()) {
const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim(); const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
@ -207,7 +217,7 @@ class SttService {
}; };
const handleTheirMessage = message => { const handleTheirMessage = message => {
if (isGemini) { if (this.modelInfo.provider === 'gemini') {
const text = message.serverContent?.inputTranscription?.text || ''; const text = message.serverContent?.inputTranscription?.text || '';
if (text && text.trim()) { if (text && text.trim()) {
const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim(); const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
@ -265,20 +275,20 @@ class SttService {
}; };
// Determine auth options for providers that support it // Determine auth options for providers that support it
const authService = require('../../../common/services/authService'); // const authService = require('../../../common/services/authService');
const userState = authService.getCurrentUser(); // const userState = authService.getCurrentUser();
const loggedIn = userState.isLoggedIn; // const loggedIn = userState.isLoggedIn;
const sttOptions = { const sttOptions = {
apiKey: API_KEY, apiKey: this.modelInfo.apiKey,
language: effectiveLanguage, language: effectiveLanguage,
usePortkey: !isGemini && loggedIn, // Only OpenAI supports Portkey usePortkey: this.modelInfo.provider === 'openai-glass',
portkeyVirtualKey: loggedIn ? API_KEY : undefined portkeyVirtualKey: this.modelInfo.provider === 'openai-glass' ? this.modelInfo.apiKey : undefined,
}; };
[this.mySttSession, this.theirSttSession] = await Promise.all([ [this.mySttSession, this.theirSttSession] = await Promise.all([
createSTT(provider, { ...sttOptions, callbacks: mySttConfig.callbacks }), createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: mySttConfig.callbacks }),
createSTT(provider, { ...sttOptions, callbacks: theirSttConfig.callbacks }), createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: theirSttConfig.callbacks }),
]); ]);
console.log('✅ Both STT sessions initialized successfully.'); console.log('✅ Both STT sessions initialized successfully.');
@ -286,14 +296,23 @@ class SttService {
} }
async sendAudioContent(data, mimeType) { async sendAudioContent(data, mimeType) {
const provider = await this.getAiProvider(); // const provider = await this.getAiProvider();
const isGemini = provider === 'gemini'; // const isGemini = provider === 'gemini';
if (!this.mySttSession) { if (!this.mySttSession) {
throw new Error('User STT session not active'); throw new Error('User STT session not active');
} }
const payload = isGemini let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
const payload = modelInfo.provider === 'gemini'
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } ? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
: data; : data;
@ -362,8 +381,17 @@ class SttService {
let audioBuffer = Buffer.alloc(0); let audioBuffer = Buffer.alloc(0);
const provider = await this.getAiProvider(); // const provider = await this.getAiProvider();
const isGemini = provider === 'gemini'; // const isGemini = provider === 'gemini';
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
this.systemAudioProc.stdout.on('data', async data => { this.systemAudioProc.stdout.on('data', async data => {
audioBuffer = Buffer.concat([audioBuffer, data]); audioBuffer = Buffer.concat([audioBuffer, data]);
@ -379,7 +407,7 @@ class SttService {
if (this.theirSttSession) { if (this.theirSttSession) {
try { try {
const payload = isGemini const payload = modelInfo.provider === 'gemini'
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } } ? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
: base64Data; : base64Data;
await this.theirSttSession.sendRealtimeInput(payload); await this.theirSttSession.sendRealtimeInput(payload);
@ -472,6 +500,7 @@ class SttService {
this.theirLastPartialText = ''; this.theirLastPartialText = '';
this.myCompletionBuffer = ''; this.myCompletionBuffer = '';
this.theirCompletionBuffer = ''; this.theirCompletionBuffer = '';
this.modelInfo = null;
} }
} }

View File

@ -4,7 +4,7 @@ const { createLLM } = require('../../../common/ai/factory');
const authService = require('../../../common/services/authService'); const authService = require('../../../common/services/authService');
const sessionRepository = require('../../../common/repositories/session'); const sessionRepository = require('../../../common/repositories/session');
const summaryRepository = require('./repositories'); const summaryRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider } = require('../../../electron/windowManager'); const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager');
class SummaryService { class SummaryService {
constructor() { constructor() {
@ -27,22 +27,22 @@ class SummaryService {
this.currentSessionId = sessionId; this.currentSessionId = sessionId;
} }
async getApiKey() { // async getApiKey() {
const storedKey = await getStoredApiKey(); // const storedKey = await getStoredApiKey();
if (storedKey) { // if (storedKey) {
console.log('[SummaryService] Using stored API key'); // console.log('[SummaryService] Using stored API key');
return storedKey; // return storedKey;
} // }
const envKey = process.env.OPENAI_API_KEY; // const envKey = process.env.OPENAI_API_KEY;
if (envKey) { // if (envKey) {
console.log('[SummaryService] Using environment API key'); // console.log('[SummaryService] Using environment API key');
return envKey; // return envKey;
} // }
console.error('[SummaryService] No API key found in storage or environment'); // console.error('[SummaryService] No API key found in storage or environment');
return null; // return null;
} // }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { BrowserWindow.getAllWindows().forEach(win => {
@ -115,6 +115,12 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId); await sessionRepository.touch(this.currentSessionId);
} }
const modelInfo = await getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.');
}
console.log(`🤖 Sending analysis request to ${modelInfo.provider} using model ${modelInfo.model}`);
const messages = [ const messages = [
{ {
role: 'system', role: 'system',
@ -148,23 +154,13 @@ Keep all points concise and build upon previous analysis if provided.`,
console.log('🤖 Sending analysis request to AI...'); console.log('🤖 Sending analysis request to AI...');
const API_KEY = await this.getApiKey(); const llm = createLLM(modelInfo.provider, {
if (!API_KEY) { apiKey: modelInfo.apiKey,
throw new Error('No API key available'); model: modelInfo.model,
}
const provider = getStoredProvider ? await getStoredProvider() : 'openai';
const loggedIn = authService.getCurrentUser().isLoggedIn;
console.log(`[SummaryService] provider: ${provider}, loggedIn: ${loggedIn}`);
const llm = createLLM(provider, {
apiKey: API_KEY,
model: provider === 'openai' ? 'gpt-4.1' : 'gemini-2.5-flash',
temperature: 0.7, temperature: 0.7,
maxTokens: 1024, maxTokens: 1024,
usePortkey: provider === 'openai' && loggedIn, usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: loggedIn ? API_KEY : undefined portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
}); });
const completion = await llm.chat(messages); const completion = await llm.chat(messages);
@ -180,7 +176,7 @@ Keep all points concise and build upon previous analysis if provided.`,
tldr: structuredData.summary.join('\n'), tldr: structuredData.summary.join('\n'),
bullet_json: JSON.stringify(structuredData.topic.bullets), bullet_json: JSON.stringify(structuredData.topic.bullets),
action_json: JSON.stringify(structuredData.actions), action_json: JSON.stringify(structuredData.actions),
model: 'gpt-4.1' model: modelInfo.model
}).catch(err => console.error('[DB] Failed to save summary:', err)); }).catch(err => console.error('[DB] Failed to save summary:', err));
} }
@ -192,7 +188,6 @@ Keep all points concise and build upon previous analysis if provided.`,
conversationLength: conversationTexts.length, conversationLength: conversationTexts.length,
}); });
// 히스토리 크기 제한 (최근 10개만 유지)
if (this.analysisHistory.length > 10) { if (this.analysisHistory.length > 10) {
this.analysisHistory.shift(); this.analysisHistory.shift();
} }

View File

@ -375,6 +375,43 @@ export class SettingsView extends LitElement {
display: none; display: none;
} }
.api-key-section, .model-selection-section {
padding: 8px 0;
border-top: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
flex-direction: column;
gap: 10px;
}
.provider-key-group, .model-select-group {
display: flex;
flex-direction: column;
gap: 4px;
}
label {
font-size: 11px;
font-weight: 500;
color: rgba(255, 255, 255, 0.8);
margin-left: 2px;
}
label > strong {
color: white;
font-weight: 600;
}
.provider-key-group input {
width: 100%; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.2);
color: white; border-radius: 4px; padding: 5px 8px; font-size: 11px; box-sizing: border-box;
}
.key-buttons { display: flex; gap: 4px; }
.key-buttons .settings-button { flex: 1; padding: 4px; }
.model-list {
display: flex; flex-direction: column; gap: 2px; max-height: 120px;
overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 4px;
padding: 4px; margin-top: 4px;
}
.model-item { padding: 5px 8px; font-size: 11px; border-radius: 3px; cursor: pointer; transition: background-color 0.15s; }
.model-item:hover { background-color: rgba(255,255,255,0.1); }
.model-item.selected { background-color: rgba(0, 122, 255, 0.4); font-weight: 500; }
/* ────────────────[ GLASS BYPASS ]─────────────── */ /* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) { :host-context(body.has-glass) {
animation: none !important; animation: none !important;
@ -400,71 +437,237 @@ export class SettingsView extends LitElement {
} }
`; `;
//////// before_modelStateService ////////
// static properties = {
// firebaseUser: { type: Object, state: true },
// apiKey: { type: String, state: true },
// isLoading: { type: Boolean, state: true },
// isContentProtectionOn: { type: Boolean, state: true },
// settings: { type: Object, state: true },
// presets: { type: Array, state: true },
// selectedPreset: { type: Object, state: true },
// showPresets: { type: Boolean, state: true },
// saving: { type: Boolean, state: true },
// };
//////// before_modelStateService ////////
//////// after_modelStateService ////////
static properties = { static properties = {
firebaseUser: { type: Object, state: true }, firebaseUser: { type: Object, state: true },
apiKey: { type: String, state: true },
isLoading: { type: Boolean, state: true }, isLoading: { type: Boolean, state: true },
isContentProtectionOn: { type: Boolean, state: true }, isContentProtectionOn: { type: Boolean, state: true },
settings: { type: Object, state: true }, saving: { type: Boolean, state: true },
providerConfig: { type: Object, state: true },
apiKeys: { type: Object, state: true },
availableLlmModels: { type: Array, state: true },
availableSttModels: { type: Array, state: true },
selectedLlm: { type: String, state: true },
selectedStt: { type: String, state: true },
isLlmListVisible: { type: Boolean },
isSttListVisible: { type: Boolean },
presets: { type: Array, state: true }, presets: { type: Array, state: true },
selectedPreset: { type: Object, state: true }, selectedPreset: { type: Object, state: true },
showPresets: { type: Boolean, state: true }, showPresets: { type: Boolean, state: true },
saving: { type: Boolean, state: true },
}; };
//////// after_modelStateService ////////
constructor() { constructor() {
super(); super();
//////// before_modelStateService ////////
// this.firebaseUser = null;
// this.apiKey = null;
// this.isLoading = false;
// this.isContentProtectionOn = true;
// this.settings = null;
// this.presets = [];
// this.selectedPreset = null;
// this.showPresets = false;
// this.saving = false;
// this.loadInitialData();
//////// before_modelStateService ////////
//////// after_modelStateService ////////
this.firebaseUser = null; this.firebaseUser = null;
this.apiKey = null; this.apiKeys = { openai: '', gemini: '', anthropic: '' };
this.isLoading = false; this.providerConfig = {};
this.isLoading = true;
this.isContentProtectionOn = true; this.isContentProtectionOn = true;
this.settings = null; this.saving = false;
this.availableLlmModels = [];
this.availableSttModels = [];
this.selectedLlm = null;
this.selectedStt = null;
this.isLlmListVisible = false;
this.isSttListVisible = false;
this.presets = []; this.presets = [];
this.selectedPreset = null; this.selectedPreset = null;
this.showPresets = false; this.showPresets = false;
this.saving = false; this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this)
this.loadInitialData(); this.loadInitialData();
//////// after_modelStateService ////////
} }
//////// before_modelStateService ////////
// async loadInitialData() {
// if (!window.require) return;
// try {
// this.isLoading = true;
// const { ipcRenderer } = window.require('electron');
// // Load all data in parallel
// const [settings, presets, apiKey, contentProtection, userState] = await Promise.all([
// ipcRenderer.invoke('settings:getSettings'),
// ipcRenderer.invoke('settings:getPresets'),
// ipcRenderer.invoke('get-stored-api-key'),
// ipcRenderer.invoke('get-content-protection-status'),
// ipcRenderer.invoke('get-current-user')
// ]);
// this.settings = settings;
// this.presets = presets || [];
// this.apiKey = apiKey;
// this.isContentProtectionOn = contentProtection;
// // Set first user preset as selected
// if (this.presets.length > 0) {
// const firstUserPreset = this.presets.find(p => p.is_default === 0);
// if (firstUserPreset) {
// this.selectedPreset = firstUserPreset;
// }
// }
// if (userState && userState.isLoggedIn) {
// this.firebaseUser = userState.user;
// }
// } catch (error) {
// console.error('Error loading initial data:', error);
// } finally {
// this.isLoading = false;
// }
// }
//////// before_modelStateService ////////
//////// after_modelStateService ////////
async loadInitialData() { async loadInitialData() {
if (!window.require) return; if (!window.require) return;
try {
this.isLoading = true; this.isLoading = true;
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
try {
// Load all data in parallel const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection] = await Promise.all([
const [settings, presets, apiKey, contentProtection, userState] = await Promise.all([ ipcRenderer.invoke('get-current-user'),
ipcRenderer.invoke('settings:getSettings'), ipcRenderer.invoke('model:get-provider-config'), // Provider 설정 로드
ipcRenderer.invoke('model:get-all-keys'),
ipcRenderer.invoke('model:get-available-models', { type: 'llm' }),
ipcRenderer.invoke('model:get-available-models', { type: 'stt' }),
ipcRenderer.invoke('model:get-selected-models'),
ipcRenderer.invoke('settings:getPresets'), ipcRenderer.invoke('settings:getPresets'),
ipcRenderer.invoke('get-stored-api-key'), ipcRenderer.invoke('get-content-protection-status')
ipcRenderer.invoke('get-content-protection-status'),
ipcRenderer.invoke('get-current-user')
]); ]);
this.settings = settings; if (userState && userState.isLoggedIn) this.firebaseUser = userState;
this.providerConfig = config;
this.apiKeys = storedKeys;
this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt;
this.selectedLlm = selectedModels.llm;
this.selectedStt = selectedModels.stt;
this.presets = presets || []; this.presets = presets || [];
this.apiKey = apiKey;
this.isContentProtectionOn = contentProtection; this.isContentProtectionOn = contentProtection;
// Set first user preset as selected
if (this.presets.length > 0) { if (this.presets.length > 0) {
const firstUserPreset = this.presets.find(p => p.is_default === 0); const firstUserPreset = this.presets.find(p => p.is_default === 0);
if (firstUserPreset) { if (firstUserPreset) this.selectedPreset = firstUserPreset;
this.selectedPreset = firstUserPreset;
}
}
if (userState && userState.isLoggedIn) {
this.firebaseUser = userState.user;
} }
} catch (error) { } catch (error) {
console.error('Error loading initial data:', error); console.error('Error loading initial settings data:', error);
} finally { } finally {
this.isLoading = false; this.isLoading = false;
} }
} }
async handleSaveKey(provider) {
const input = this.shadowRoot.querySelector(`#key-input-${provider}`);
if (!input) return;
const key = input.value;
this.saving = true;
const { ipcRenderer } = window.require('electron');
const result = await ipcRenderer.invoke('model:validate-key', { provider, key });
if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: key };
await this.refreshModelData();
} else {
alert(`Failed to save ${provider} key: ${result.error}`);
input.value = this.apiKeys[provider] || '';
}
this.saving = false;
}
async handleClearKey(provider) {
this.saving = true;
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('model:remove-api-key', { provider });
this.apiKeys = { ...this.apiKeys, [provider]: '' };
await this.refreshModelData();
this.saving = false;
}
async refreshModelData() {
const { ipcRenderer } = window.require('electron');
const [availableLlm, availableStt, selected] = await Promise.all([
ipcRenderer.invoke('model:get-available-models', { type: 'llm' }),
ipcRenderer.invoke('model:get-available-models', { type: 'stt' }),
ipcRenderer.invoke('model:get-selected-models')
]);
this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt;
this.selectedLlm = selected.llm;
this.selectedStt = selected.stt;
this.requestUpdate();
}
async toggleModelList(type) {
const visibilityProp = type === 'llm' ? 'isLlmListVisible' : 'isSttListVisible';
if (!this[visibilityProp]) {
this.saving = true;
this.requestUpdate();
await this.refreshModelData();
this.saving = false;
}
// 데이터 새로고침 후, 목록의 표시 상태를 토글합니다.
this[visibilityProp] = !this[visibilityProp];
this.requestUpdate();
}
async selectModel(type, modelId) {
this.saving = true;
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('model:set-selected-model', { type, modelId });
if (type === 'llm') this.selectedLlm = modelId;
if (type === 'stt') this.selectedStt = modelId;
this.isLlmListVisible = false;
this.isSttListVisible = false;
this.saving = false;
this.requestUpdate();
}
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")
}
}
//////// after_modelStateService ////////
connectedCallback() { connectedCallback() {
super.connectedCallback(); super.connectedCallback();
@ -697,6 +900,140 @@ export class SettingsView extends LitElement {
} }
} }
//////// before_modelStateService ////////
// render() {
// if (this.isLoading) {
// return html`
// <div class="settings-container">
// <div class="loading-state">
// <div class="loading-spinner"></div>
// <span>Loading...</span>
// </div>
// </div>
// `;
// }
// const loggedIn = !!this.firebaseUser;
// return html`
// <div class="settings-container">
// <div class="header-section">
// <div>
// <h1 class="app-title">Pickle Glass</h1>
// <div class="account-info">
// ${this.firebaseUser
// ? html`Account: ${this.firebaseUser.email || 'Logged In'}`
// : this.apiKey && this.apiKey.length > 10
// ? html`API Key: ${this.apiKey.substring(0, 6)}...${this.apiKey.substring(this.apiKey.length - 6)}`
// : `Account: Not Logged In`
// }
// </div>
// </div>
// <div class="invisibility-icon ${this.isContentProtectionOn ? 'visible' : ''}" title="Invisibility is On">
// <svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
// <path d="M9.785 7.41787C8.7 7.41787 7.79 8.19371 7.55667 9.22621C7.0025 8.98704 6.495 9.05121 6.11 9.22037C5.87083 8.18204 4.96083 7.41787 3.88167 7.41787C2.61583 7.41787 1.58333 8.46204 1.58333 9.75121C1.58333 11.0404 2.61583 12.0845 3.88167 12.0845C5.08333 12.0845 6.06333 11.1395 6.15667 9.93787C6.355 9.79787 6.87417 9.53537 7.51 9.94954C7.615 11.1454 8.58333 12.0845 9.785 12.0845C11.0508 12.0845 12.0833 11.0404 12.0833 9.75121C12.0833 8.46204 11.0508 7.41787 9.785 7.41787ZM3.88167 11.4195C2.97167 11.4195 2.2425 10.6729 2.2425 9.75121C2.2425 8.82954 2.9775 8.08287 3.88167 8.08287C4.79167 8.08287 5.52083 8.82954 5.52083 9.75121C5.52083 10.6729 4.79167 11.4195 3.88167 11.4195ZM9.785 11.4195C8.875 11.4195 8.14583 10.6729 8.14583 9.75121C8.14583 8.82954 8.875 8.08287 9.785 8.08287C10.695 8.08287 11.43 8.82954 11.43 9.75121C11.43 10.6729 10.6892 11.4195 9.785 11.4195ZM12.6667 5.95954H1V6.83454H12.6667V5.95954ZM8.8925 1.36871C8.76417 1.08287 8.4375 0.931207 8.12833 1.03037L6.83333 1.46204L5.5325 1.03037L5.50333 1.02454C5.19417 0.93704 4.8675 1.10037 4.75083 1.39787L3.33333 5.08454H10.3333L8.91 1.39787L8.8925 1.36871Z" fill="white"/>
// </svg>
// </div>
// </div>
// <div class="api-key-section">
// <input
// type="password"
// id="api-key-input"
// placeholder="Enter API Key"
// .value=${this.apiKey || ''}
// ?disabled=${loggedIn}
// >
// <button class="settings-button full-width" @click=${this.handleSaveApiKey} ?disabled=${loggedIn}>
// Save API Key
// </button>
// </div>
// <div class="shortcuts-section">
// ${this.getMainShortcuts().map(shortcut => html`
// <div class="shortcut-item">
// <span class="shortcut-name">${shortcut.name}</span>
// <div class="shortcut-keys">
// <span class="cmd-key">⌘</span>
// <span class="shortcut-key">${shortcut.key}</span>
// </div>
// </div>
// `)}
// </div>
// <!-- Preset Management Section -->
// <div class="preset-section">
// <div class="preset-header">
// <span class="preset-title">
// My Presets
// <span class="preset-count">(${this.presets.filter(p => p.is_default === 0).length})</span>
// </span>
// <span class="preset-toggle" @click=${this.togglePresets}>
// ${this.showPresets ? '▼' : '▶'}
// </span>
// </div>
// <div class="preset-list ${this.showPresets ? '' : 'hidden'}">
// ${this.presets.filter(p => p.is_default === 0).length === 0 ? html`
// <div class="no-presets-message">
// No custom presets yet.<br>
// <span class="web-link" @click=${this.handlePersonalize}>
// Create your first preset
// </span>
// </div>
// ` : this.presets.filter(p => p.is_default === 0).map(preset => html`
// <div class="preset-item ${this.selectedPreset?.id === preset.id ? 'selected' : ''}"
// @click=${() => this.handlePresetSelect(preset)}>
// <span class="preset-name">${preset.title}</span>
// ${this.selectedPreset?.id === preset.id ? html`<span class="preset-status">Selected</span>` : ''}
// </div>
// `)}
// </div>
// </div>
// <div class="buttons-section">
// <button class="settings-button full-width" @click=${this.handlePersonalize}>
// <span>Personalize / Meeting Notes</span>
// </button>
// <div class="move-buttons">
// <button class="settings-button half-width" @click=${this.handleMoveLeft}>
// <span>← Move</span>
// </button>
// <button class="settings-button half-width" @click=${this.handleMoveRight}>
// <span>Move →</span>
// </button>
// </div>
// <button class="settings-button full-width" @click=${this.handleToggleInvisibility}>
// <span>${this.isContentProtectionOn ? 'Disable Invisibility' : 'Enable Invisibility'}</span>
// </button>
// <div class="bottom-buttons">
// ${this.firebaseUser
// ? html`
// <button class="settings-button half-width danger" @click=${this.handleFirebaseLogout}>
// <span>Logout</span>
// </button>
// `
// : html`
// <button class="settings-button half-width danger" @click=${this.handleClearApiKey}>
// <span>Clear API Key</span>
// </button>
// `
// }
// <button class="settings-button half-width danger" @click=${this.handleQuit}>
// <span>Quit</span>
// </button>
// </div>
// </div>
// </div>
// `;
// }
//////// before_modelStateService ////////
//////// after_modelStateService ////////
render() { render() {
if (this.isLoading) { if (this.isLoading) {
return html` return html`
@ -711,6 +1048,68 @@ export class SettingsView extends LitElement {
const loggedIn = !!this.firebaseUser; const loggedIn = !!this.firebaseUser;
const apiKeyManagementHTML = html`
<div class="api-key-section">
${Object.entries(this.providerConfig)
.filter(([id, config]) => !id.includes('-glass'))
.map(([id, config]) => html`
<div class="provider-key-group">
<label for="key-input-${id}">${config.name} API Key</label>
<input type="password" id="key-input-${id}"
placeholder=${loggedIn ? "Using Pickle's Key" : `Enter ${config.name} API Key`}
.value=${this.apiKeys[id] || ''}
>
<div class="key-buttons">
<button class="settings-button" @click=${() => this.handleSaveKey(id)} >Save</button>
<button class="settings-button danger" @click=${() => this.handleClearKey(id)} }>Clear</button>
</div>
</div>
`)}
</div>
`;
const getModelName = (type, id) => {
const models = type === 'llm' ? this.availableLlmModels : this.availableSttModels;
const model = models.find(m => m.id === id);
return model ? model.name : id;
}
const modelSelectionHTML = html`
<div class="model-selection-section">
<div class="model-select-group">
<label>LLM Model: <strong>${getModelName('llm', this.selectedLlm) || 'Not Set'}</strong></label>
<button class="settings-button full-width" @click=${() => this.toggleModelList('llm')} ?disabled=${this.saving || this.availableLlmModels.length === 0}>
Change LLM Model
</button>
${this.isLlmListVisible ? html`
<div class="model-list">
${this.availableLlmModels.map(model => html`
<div class="model-item ${this.selectedLlm === model.id ? 'selected' : ''}" @click=${() => this.selectModel('llm', model.id)}>
${model.name}
</div>
`)}
</div>
` : ''}
</div>
<div class="model-select-group">
<label>STT Model: <strong>${getModelName('stt', this.selectedStt) || 'Not Set'}</strong></label>
<button class="settings-button full-width" @click=${() => this.toggleModelList('stt')} ?disabled=${this.saving || this.availableSttModels.length === 0}>
Change STT Model
</button>
${this.isSttListVisible ? html`
<div class="model-list">
${this.availableSttModels.map(model => html`
<div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}" @click=${() => this.selectModel('stt', model.id)}>
${model.name}
</div>
`)}
</div>
` : ''}
</div>
</div>
`;
return html` return html`
<div class="settings-container"> <div class="settings-container">
<div class="header-section"> <div class="header-section">
@ -719,8 +1118,6 @@ export class SettingsView extends LitElement {
<div class="account-info"> <div class="account-info">
${this.firebaseUser ${this.firebaseUser
? html`Account: ${this.firebaseUser.email || 'Logged In'}` ? html`Account: ${this.firebaseUser.email || 'Logged In'}`
: this.apiKey && this.apiKey.length > 10
? html`API Key: ${this.apiKey.substring(0, 6)}...${this.apiKey.substring(this.apiKey.length - 6)}`
: `Account: Not Logged In` : `Account: Not Logged In`
} }
</div> </div>
@ -732,18 +1129,8 @@ export class SettingsView extends LitElement {
</div> </div>
</div> </div>
<div class="api-key-section"> ${apiKeyManagementHTML}
<input ${modelSelectionHTML}
type="password"
id="api-key-input"
placeholder="Enter API Key"
.value=${this.apiKey || ''}
?disabled=${loggedIn}
>
<button class="settings-button full-width" @click=${this.handleSaveApiKey} ?disabled=${loggedIn}>
Save API Key
</button>
</div>
<div class="shortcuts-section"> <div class="shortcuts-section">
${this.getMainShortcuts().map(shortcut => html` ${this.getMainShortcuts().map(shortcut => html`
@ -757,7 +1144,6 @@ export class SettingsView extends LitElement {
`)} `)}
</div> </div>
<!-- Preset Management Section -->
<div class="preset-section"> <div class="preset-section">
<div class="preset-header"> <div class="preset-header">
<span class="preset-title"> <span class="preset-title">
@ -813,8 +1199,8 @@ export class SettingsView extends LitElement {
</button> </button>
` `
: html` : html`
<button class="settings-button half-width danger" @click=${this.handleClearApiKey}> <button class="settings-button half-width" @click=${this.handleUsePicklesKey}>
<span>Clear API Key</span> <span>Login</span>
</button> </button>
` `
} }
@ -826,6 +1212,7 @@ export class SettingsView extends LitElement {
</div> </div>
`; `;
} }
//////// after_modelStateService ////////
} }
customElements.define('settings-view', SettingsView); customElements.define('settings-view', SettingsView);

View File

@ -25,6 +25,7 @@ const { EventEmitter } = require('events');
const askService = require('./features/ask/askService'); const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService'); const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./common/repositories/session'); const sessionRepository = require('./common/repositories/session');
const ModelStateService = require('./common/services/modelStateService');
const eventBridge = new EventEmitter(); const eventBridge = new EventEmitter();
let WEB_PORT = 3000; let WEB_PORT = 3000;
@ -33,6 +34,11 @@ const listenService = new ListenService();
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance // Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
global.listenService = listenService; global.listenService = listenService;
//////// after_modelStateService ////////
const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService;
//////// after_modelStateService ////////
// Native deep link handling - cross-platform compatible // Native deep link handling - cross-platform compatible
let pendingDeepLinkUrl = null; let pendingDeepLinkUrl = null;
@ -173,6 +179,9 @@ app.whenReady().then(async () => {
sessionRepository.endAllActiveSessions(); sessionRepository.endAllActiveSessions();
authService.initialize(); authService.initialize();
//////// after_modelStateService ////////
modelStateService.initialize();
//////// after_modelStateService ////////
listenService.setupIpcHandlers(); listenService.setupIpcHandlers();
askService.initialize(); askService.initialize();
settingsService.initialize(); settingsService.initialize();