diff --git a/src/app/ApiKeyHeader.js b/src/app/ApiKeyHeader.js
index 25f1571..92962c8 100644
--- a/src/app/ApiKeyHeader.js
+++ b/src/app/ApiKeyHeader.js
@@ -1,12 +1,17 @@
import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js"
export class ApiKeyHeader extends LitElement {
+ //////// after_modelStateService ////////
static properties = {
- apiKey: { type: String },
+ llmApiKey: { type: String },
+ sttApiKey: { type: String },
+ llmProvider: { type: String },
+ sttProvider: { type: String },
isLoading: { type: Boolean },
errorMessage: { type: String },
- selectedProvider: { type: String },
+ providers: { type: Object, state: true },
}
+ //////// after_modelStateService ////////
static styles = css`
:host {
@@ -45,7 +50,7 @@ export class ApiKeyHeader extends LitElement {
}
.container {
- width: 285px;
+ width: 350px;
min-height: 260px;
padding: 18px 20px;
background: rgba(0, 0, 0, 0.3);
@@ -153,28 +158,22 @@ export class ApiKeyHeader extends LitElement {
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%;
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;
- font-weight: 400;
margin-bottom: 6px;
- text-align: center;
- cursor: pointer;
- -webkit-appearance: none;
- -moz-appearance: none;
- appearance: none;
- background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2714%27%20height%3D%278%27%20viewBox%3D%270%200%2014%208%27%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%3E%3Cpath%20d%3D%27M1%201l6%206%206-6%27%20stroke%3D%27%23ffffff%27%20stroke-width%3D%271.5%27%20fill%3D%27none%27%20fill-rule%3D%27evenodd%27/%3E%3C/svg%3E');
- background-repeat: no-repeat;
- background-position: right 10px center;
- background-size: 12px;
- padding-right: 30px;
}
+ .provider-select option { background: #1a1a1a; color: white; }
.provider-select:hover {
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);
}
- .provider-select option {
- background: #1a1a1a;
- color: white;
- padding: 5px;
- }
.action-button {
width: 100%;
@@ -239,15 +233,7 @@ export class ApiKeyHeader extends LitElement {
font-weight: 500; /* Medium */
margin: 10px 0;
}
-
- .provider-label {
- color: rgba(255, 255, 255, 0.7);
- font-size: 11px;
- font-weight: 400;
- margin-bottom: 4px;
- width: 100%;
- text-align: left;
- }
+
/* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) .container,
@@ -278,11 +264,16 @@ export class ApiKeyHeader extends LitElement {
super()
this.dragState = null
this.wasJustDragged = false
- this.apiKey = ""
this.isLoading = false
this.errorMessage = ""
- this.validatedApiKey = null
- this.selectedProvider = "openai"
+ //////// 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)
@@ -303,6 +294,35 @@ export class ApiKeyHeader extends LitElement {
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
@@ -409,144 +429,45 @@ export class ApiKeyHeader extends LitElement {
}
}
+ //////// after_modelStateService ////////
async handleSubmit() {
- if (this.wasJustDragged || this.isLoading || !this.apiKey.trim()) {
- console.log("Submit blocked:", {
- wasJustDragged: this.wasJustDragged,
- isLoading: this.isLoading,
- hasApiKey: !!this.apiKey.trim(),
- })
- return
+ console.log('[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;
}
- console.log("Starting API key validation...")
- this.isLoading = true
- this.errorMessage = ""
- this.requestUpdate()
+ this.isLoading = true;
+ this.errorMessage = "";
+ this.requestUpdate();
- const apiKey = this.apiKey.trim()
- const isValid = false
- try {
- const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider)
+ const { ipcRenderer } = window.require('electron');
- if (isValid) {
- console.log("API key valid - starting slide out animation")
- this.startSlideOutAnimation()
- this.validatedApiKey = this.apiKey.trim()
- this.validatedProvider = this.selectedProvider
- } else {
- this.errorMessage = "Invalid API key - please check and try again"
- console.log("API key validation failed")
- }
- } catch (error) {
- console.error("API key validation error:", error)
- this.errorMessage = "Validation error - please try again"
- } finally {
- this.isLoading = false
- this.requestUpdate()
- }
- }
+ 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() });
- async validateApiKey(apiKey, provider = "openai") {
- if (!apiKey || apiKey.length < 15) return false
+ const [llmResult, sttResult] = await Promise.all([llmValidation, sttValidation]);
- 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
- }
+ 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(' | ');
}
- return false
- }
+ this.isLoading = false;
+ this.requestUpdate();
+}
+//////// after_modelStateService ////////
+
startSlideOutAnimation() {
+ console.log('[ApiKeyHeader] startSlideOutAnimation: Starting slide out animation.');
this.classList.add("sliding-out")
}
@@ -567,25 +488,18 @@ export class ApiKeyHeader extends LitElement {
}
}
+
+ //////// after_modelStateService ////////
handleAnimationEnd(e) {
- if (e.target !== this) return
-
- if (this.classList.contains("sliding-out")) {
- this.classList.remove("sliding-out")
- this.classList.add("hidden")
-
- if (this.validatedApiKey) {
- if (window.require) {
- window.require("electron").ipcRenderer.invoke("api-key-validated", {
- apiKey: this.validatedApiKey,
- provider: this.validatedProvider || "openai",
- })
- }
- this.validatedApiKey = null
- this.validatedProvider = null
- }
- }
+ 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()
@@ -598,64 +512,40 @@ export class ApiKeyHeader extends LitElement {
}
render() {
- const isButtonDisabled = this.isLoading || !this.apiKey || !this.apiKey.trim()
- console.log("Rendering with provider:", this.selectedProvider)
+ const isButtonDisabled = this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim();
return html`
-
-
-
Choose how to power your AI
+
+
Enter Your API Keys
-
-
${this.errorMessage}
-
Select AI Provider:
-
+ `;
+}
}
customElements.define("apikey-header", ApiKeyHeader)
diff --git a/src/app/HeaderController.js b/src/app/HeaderController.js
index 26313f1..e2c5fe8 100644
--- a/src/app/HeaderController.js
+++ b/src/app/HeaderController.js
@@ -15,7 +15,11 @@ class HeaderTransitionManager {
* @param {'apikey'|'main'|'permission'} 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 = '';
@@ -26,6 +30,7 @@ class HeaderTransitionManager {
// Create new header element
if (type === 'apikey') {
this.apiKeyHeader = document.createElement('apikey-header');
+ this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
this.headerContainer.appendChild(this.apiKeyHeader);
} else if (type === 'permission') {
this.permissionHeader = document.createElement('permission-setup');
@@ -60,6 +65,11 @@ class HeaderTransitionManager {
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;
- if (isLoggedIn) {
- // Firebase user: Check permissions, then show Main or Permission header
- const permissionResult = await this.checkPermissions();
- if (permissionResult.success) {
- this.transitionToMainHeader();
+ //////// 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) {
+ const permissionResult = await this.checkPermissions();
+ if (permissionResult.success) {
+ this.transitionToMainHeader();
+ } else {
+ this.transitionToPermissionHeader();
+ }
} else {
- this.transitionToPermissionHeader();
+ this.transitionToMainHeader();
}
- } else if (hasApiKey) {
- // API Key only user: Skip permission check, go directly to Main
- this.transitionToMainHeader();
} else {
- // No auth at all
await this._resizeForApiKey();
this.ensureHeader('apikey');
}
}
+ //////// after_modelStateService ////////
async transitionToPermissionHeader() {
// Prevent duplicate transitions
@@ -159,7 +173,7 @@ class HeaderTransitionManager {
if (!window.require) return;
return window
.require('electron')
- .ipcRenderer.invoke('resize-header-window', { width: 285, height: 300 })
+ .ipcRenderer.invoke('resize-header-window', { width: 350, height: 300 })
.catch(() => {});
}
diff --git a/src/common/ai/factory.js b/src/common/ai/factory.js
index 8d0d6b5..ea86ff0 100644
--- a/src/common/ai/factory.js
+++ b/src/common/ai/factory.js
@@ -1,68 +1,121 @@
-const providers = {
- openai: require("./providers/openai"),
- gemini: require("./providers/gemini"),
- anthropic: require("./providers/anthropic"),
- // 추가 provider는 여기에 등록
-}
+// factory.js
/**
- * Creates an STT session based on provider
- * @param {string} provider - Provider name ('openai', 'gemini', etc.)
- * @param {object} opts - Configuration options (apiKey, language, callbacks, etc.)
- * @returns {Promise
-
-
-
-
-
+ ${apiKeyManagementHTML}
+ ${modelSelectionHTML}
+
${this.getMainShortcuts().map(shortcut => html`
@@ -757,7 +1144,6 @@ export class SettingsView extends LitElement {
`)}
-
`;
}
+ //////// after_modelStateService ////////
}
customElements.define('settings-view', SettingsView);
\ No newline at end of file
diff --git a/src/index.js b/src/index.js
index e0fcde4..e7f9a07 100644
--- a/src/index.js
+++ b/src/index.js
@@ -25,6 +25,7 @@ const { EventEmitter } = require('events');
const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./common/repositories/session');
+const ModelStateService = require('./common/services/modelStateService');
const eventBridge = new EventEmitter();
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
global.listenService = listenService;
+//////// after_modelStateService ////////
+const modelStateService = new ModelStateService(authService);
+global.modelStateService = modelStateService;
+//////// after_modelStateService ////////
+
// Native deep link handling - cross-platform compatible
let pendingDeepLinkUrl = null;
@@ -173,6 +179,9 @@ app.whenReady().then(async () => {
sessionRepository.endAllActiveSessions();
authService.initialize();
+ //////// after_modelStateService ////////
+ modelStateService.initialize();
+ //////// after_modelStateService ////////
listenService.setupIpcHandlers();
askService.initialize();
settingsService.initialize();