From 1e593d4414fd4fbc84170490708dbd28ad4a9eda Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Mon, 10 Aug 2026 15:34:41 +0530 Subject: [PATCH 1/8] feat: add OpenRouter as a selectable LLM provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds OpenRouter (https://openrouter.ai) as a second, selectable LLM backend alongside the existing Google Gemini path. All changes are strictly additive — the Gemini code path is completely untouched and remains the default. Changes: - src/services/openrouter.service.js (new) Drop-in replacement for llm.service.js exposing the identical public API (processImageWithSkill, processTextWithSkillStream, processTranscriptionWithIntelligentResponseStream, testConnection, updateApiKey, getStats, initializeClient, etc.). Uses Node's built-in https module — zero new npm dependencies. Converts Gemini-style requests to OpenAI chat format; image inputs use the image_url data-URI format supported by vision models. SSE streaming parses choices[0].delta.content per token. Sends HTTP-Referer and X-Title attribution headers per OpenRouter recommendations. Includes friendly error mapping for 401/402/429. - src/services/llm.factory.js (new) Four-line factory that selects openrouter.service or llm.service based on config.get('llm.provider') (read from LLM_PROVIDER env var at startup). main.js requires this instead of llm.service directly — no per-call-site changes anywhere in main.js. - src/core/config.js Added llm.provider (defaults to 'gemini') and a full llm.openrouter settings block (model, timeout, maxRetries, generation defaults) mirroring the gemini sub-object shape. - main.js Swapped require('./src/services/llm.service') -> require('./src/services/llm.factory'). Added openrouterKey, llmProvider, openrouterModel to getSettings() and saveSettings() with .env persistence and live reinit of the OpenRouter client when its key is updated via settings. - settings.html + src/ui/settings-window.js Renamed 'Gemini Settings' section to 'AI Provider'. Added a provider dropdown (gemini / openrouter) with show/hide field groups for each provider, an OpenRouter API key input, and a model input. Follows the same pattern as the existing Azure/Whisper toggle. Includes a restart-required note for provider switches. - src/core/first-run.js needsOnboarding() now checks OPENROUTER_API_KEY when LLM_PROVIDER=openrouter. getStatus() exposes openrouterConfigured and llmProvider fields. - env.example Documents LLM_PROVIDER, OPENROUTER_API_KEY, OPENROUTER_MODEL with usage comments. Usage: Set LLM_PROVIDER=openrouter and OPENROUTER_API_KEY= in .env, or pick the provider from Settings > AI Provider in the app UI. Gemini remains the default when LLM_PROVIDER is unset. --- env.example | 10 + main.js | 42 ++- settings.html | 43 ++- src/core/config.js | 18 + src/core/first-run.js | 14 + src/services/llm.factory.js | 22 ++ src/services/openrouter.service.js | 532 +++++++++++++++++++++++++++++ src/ui/settings-window.js | 32 ++ 8 files changed, 708 insertions(+), 5 deletions(-) create mode 100644 src/services/llm.factory.js create mode 100644 src/services/openrouter.service.js diff --git a/env.example b/env.example index d770c1e..82643a9 100644 --- a/env.example +++ b/env.example @@ -2,6 +2,16 @@ # Get your API key from: https://makersuite.google.com/app/apikey GEMINI_API_KEY=your_gemini_api_key_here +# LLM Provider Configuration +# Choose which AI backend to use: gemini | openrouter +# Changing this requires an app restart (the provider is selected at startup). +LLM_PROVIDER=gemini +# OpenRouter API key — required only when LLM_PROVIDER=openrouter +# Get your key from: https://openrouter.ai/keys +OPENROUTER_API_KEY=your_openrouter_key_here +# Vision-capable model to use with OpenRouter (can also be set in Settings UI) +OPENROUTER_MODEL=anthropic/claude-sonnet-4 + # Speech Recognition Configuration # Choose one provider: azure or whisper SPEECH_PROVIDER=whisper diff --git a/main.js b/main.js index 36ccf4b..be3e412 100644 --- a/main.js +++ b/main.js @@ -102,7 +102,8 @@ process.on("unhandledRejection", (reason) => { // Screen capture (image-based) const captureService = require("./src/services/capture.service"); const speechService = require("./src/services/speech.service"); -const llmService = require("./src/services/llm.service"); +// llm.factory selects openrouter.service or llm.service based on LLM_PROVIDER env var. +const llmService = require("./src/services/llm.factory"); // Managers const windowManager = require("./src/managers/window.manager"); @@ -1608,6 +1609,11 @@ class ApplicationController { whisperSegmentMs: process.env.WHISPER_SEGMENT_MS || "4000", geminiKey: process.env.GEMINI_API_KEY || "", + // OpenRouter provider fields + llmProvider: process.env.LLM_PROVIDER || "gemini", + openrouterKey: process.env.OPENROUTER_API_KEY || "", + openrouterModel: process.env.OPENROUTER_MODEL || "anthropic/claude-sonnet-4", + azureConfigured: !!process.env.AZURE_SPEECH_KEY && !!process.env.AZURE_SPEECH_REGION, speechAvailable: this.speechAvailable }; @@ -1679,6 +1685,17 @@ class ApplicationController { envUpdates.GEMINI_API_KEY = settings.geminiKey; } + // OpenRouter provider settings + if (settings.llmProvider === "openrouter" || settings.llmProvider === "gemini") { + envUpdates.LLM_PROVIDER = settings.llmProvider; + } + if (settings.openrouterKey !== undefined) { + envUpdates.OPENROUTER_API_KEY = settings.openrouterKey; + } + if (settings.openrouterModel !== undefined && settings.openrouterModel.trim()) { + envUpdates.OPENROUTER_MODEL = settings.openrouterModel.trim(); + } + // Capture the previous whisper command BEFORE persisting — persistEnvUpdates // mutates process.env in place, so comparing afterwards would always read // equal and skip the speech re-init below (the exact stale-mic-after-install @@ -1703,6 +1720,29 @@ class ApplicationController { } } + // If the OpenRouter key was saved and the current runtime service is + // OpenRouter, reinitialize its client so the key is picked up immediately. + if (settings.openrouterKey !== undefined && envUpdates.OPENROUTER_API_KEY !== undefined) { + try { + if (typeof llmService.updateApiKey === 'function' && + llmService.constructor && llmService.constructor.name === 'OpenRouterService') { + llmService.updateApiKey(settings.openrouterKey); + logger.info("OpenRouter service reinitialized after key update"); + } + } catch (e) { + logger.warn("Failed to reinitialize OpenRouter service after key update", { error: e.message }); + } + } + + // Notify UI about provider change (restart still required for factory to switch) + if (settings.llmProvider !== undefined) { + windowManager.broadcastToAllWindows("llm-provider-changed", { + provider: settings.llmProvider, + requiresRestart: true + }); + logger.info("LLM provider setting updated; restart required for factory to reload", { provider: settings.llmProvider }); + } + // Reinitialize speech service when provider OR whisper command // changes. Without the second check, the install flow (which // writes a new whisperCommand after install but keeps the same diff --git a/settings.html b/settings.html index c9768d6..dd1b5e0 100644 --- a/settings.html +++ b/settings.html @@ -459,15 +459,50 @@
- Gemini Settings + AI Provider
-
Google API Key
-
Your Google API key for Gemini models
+
LLM Provider
+
Choose your AI backend (restart required to switch)
+
+ +
+ + +
+
+
+
Google API Key
+
Your Google API key for Gemini models
+
+ +
+
+ + +
+
+
+
OpenRouter API Key
+
Get your key at openrouter.ai/keys
+
+ +
+
+
+
OpenRouter Model
+
Vision-capable model ID (e.g. anthropic/claude-sonnet-4)
+
+ +
+
+ Provider change takes effect after restarting the app. API key and model updates apply immediately if OpenRouter is already active.
-
diff --git a/src/core/config.js b/src/core/config.js index c3396ca..4feda0a 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -39,6 +39,11 @@ class ConfigManager { }, llm: { + // Active provider: 'gemini' | 'openrouter'. Reads LLM_PROVIDER env var + // at startup; a factory module (llm.factory.js) selects the right service. + // Changing this at runtime requires an app restart (Node module cache). + provider: process.env.LLM_PROVIDER || 'gemini', + gemini: { model: 'gemini-3.1-flash-lite', fallbackModels: ['gemini-2.5-flash-lite', 'gemini-3.5-flash'], @@ -53,6 +58,19 @@ class ConfigManager { maxOutputTokens: 4096, thinkingConfig: { thinkingBudget: 0 } } + }, + + openrouter: { + // Vision-capable model that also handles text well. + // Override with OPENROUTER_MODEL env var or via the settings UI. + model: process.env.OPENROUTER_MODEL || 'anthropic/claude-sonnet-4', + maxRetries: 3, + timeout: 60000, + fallbackEnabled: true, + generation: { + temperature: 0.7, + max_tokens: 4096 + } } }, diff --git a/src/core/first-run.js b/src/core/first-run.js index 6584070..7dca785 100644 --- a/src/core/first-run.js +++ b/src/core/first-run.js @@ -31,6 +31,12 @@ class FirstRunManager { if (!fs.existsSync(this.sentinelPath)) return true; if (!fs.existsSync(this.envPath)) return true; const content = this._readEnv(); + const provider = (content.LLM_PROVIDER || 'gemini').trim(); + if (provider === 'openrouter') { + // When OpenRouter is selected, check its key instead of Gemini's + const orKey = (content.OPENROUTER_API_KEY || '').trim(); + return !orKey || orKey === 'your_openrouter_key_here'; + } const gemini = (content.GEMINI_API_KEY || '').trim(); return !gemini || gemini === 'your_gemini_api_key_here'; } @@ -80,10 +86,13 @@ class FirstRunManager { getStatus() { const env = this._readEnv(); const gemini = (env.GEMINI_API_KEY || '').trim(); + const openrouter = (env.OPENROUTER_API_KEY || '').trim(); return { envExists: fs.existsSync(this.envPath), sentinelExists: fs.existsSync(this.sentinelPath), geminiConfigured: !!gemini && gemini !== 'your_gemini_api_key_here', + openrouterConfigured: !!openrouter && openrouter !== 'your_openrouter_key_here', + llmProvider: (env.LLM_PROVIDER || 'gemini').trim(), azureConfigured: !!(env.AZURE_SPEECH_KEY || '').trim() && !!(env.AZURE_SPEECH_REGION || '').trim(), whisperConfigured: !!(env.WHISPER_COMMAND || '').trim(), needsOnboarding: this.needsOnboarding() @@ -144,6 +153,11 @@ class FirstRunManager { '', 'GEMINI_API_KEY=your_gemini_api_key_here', '', + '# LLM Provider: gemini (default) | openrouter (restart required to switch)', + '# LLM_PROVIDER=gemini', + '# OPENROUTER_API_KEY=your_openrouter_key_here', + '# OPENROUTER_MODEL=anthropic/claude-sonnet-4', + '', '# Speech provider: "whisper" (local) or "azure" (cloud).', '# WHISPER_COMMAND is auto-set to the project-local venv when you', '# install Whisper through the onboarding wizard, so no PATH change', diff --git a/src/services/llm.factory.js b/src/services/llm.factory.js new file mode 100644 index 0000000..63f9c17 --- /dev/null +++ b/src/services/llm.factory.js @@ -0,0 +1,22 @@ +/** + * LLM Provider Factory + * + * Selects the active LLM service based on config.get('llm.provider'). + * The value is read once at require() time from config, which itself reads + * the LLM_PROVIDER environment variable (set in .env before app start). + * + * Changing the provider via the settings UI updates .env and process.env, + * but takes effect only on the NEXT app restart because Node caches modules. + * + * Default: 'gemini' (backward-compatible). + */ +'use strict'; + +const config = require('../core/config'); +const provider = config.get('llm.provider') || 'gemini'; + +if (provider === 'openrouter') { + module.exports = require('./openrouter.service'); +} else { + module.exports = require('./llm.service'); +} diff --git a/src/services/openrouter.service.js b/src/services/openrouter.service.js new file mode 100644 index 0000000..ec8f62d --- /dev/null +++ b/src/services/openrouter.service.js @@ -0,0 +1,532 @@ +/** + * OpenRouter LLM Service + * Drop-in replacement for llm.service.js with identical public API. + * Uses https://openrouter.ai/api/v1/chat/completions (OpenAI-compatible). + */ +'use strict'; + +const https = require('https'); +const logger = require('../core/logger').createServiceLogger('LLM'); +const config = require('../core/config'); +const { promptLoader } = require('../../prompt-loader'); + +const OPENROUTER_HOST = 'openrouter.ai'; +const OPENROUTER_PATH = '/api/v1/chat/completions'; +const HTTP_REFERER = 'https://github.com/OpenCluely/OpenCluely'; +const X_TITLE = 'OpenCluely'; + +class OpenRouterService { + constructor() { + this.apiKey = null; + this.model = null; + this.isInitialized = false; + this.requestCount = 0; + this.errorCount = 0; + this.initializeClient(); + } + + // ── Initialization ───────────────────────────────────────────────── + + initializeClient() { + const apiKey = config.getApiKey('OPENROUTER'); + if (!apiKey || apiKey === 'your_openrouter_key_here') { + logger.warn('OpenRouter API key not configured', { keyExists: !!apiKey }); + return; + } + try { + this.apiKey = apiKey; + this.model = config.get('llm.openrouter.model') || 'anthropic/claude-sonnet-4'; + this.isInitialized = true; + logger.info('OpenRouter client initialized successfully', { model: this.model }); + } catch (error) { + logger.error('Failed to initialize OpenRouter client', { error: error.message }); + } + } + + // ── Public API (mirrors llm.service.js exactly) ──────────────────── + + async processImageWithSkill(imageBuffer, mimeType, activeSkill, sessionMemory, programmingLanguage) { + if (sessionMemory === undefined) sessionMemory = []; + if (programmingLanguage === undefined) programmingLanguage = null; + if (!this.isInitialized) throw new Error('OpenRouter service not initialized. Check OPENROUTER_API_KEY configuration.'); + if (!imageBuffer || !Buffer.isBuffer(imageBuffer)) throw new Error('Invalid image buffer provided to processImageWithSkill'); + + const startTime = Date.now(); + this.requestCount++; + try { + const skillPrompt = promptLoader.getSkillPrompt(activeSkill, programmingLanguage) || ''; + const base64 = imageBuffer.toString('base64'); + const messages = this._buildImageMessages(base64, mimeType, activeSkill, programmingLanguage, skillPrompt); + const responseText = await this._executeRequest(messages); + const finalResponse = programmingLanguage ? this.enforceProgrammingLanguage(responseText, programmingLanguage) : responseText; + logger.logPerformance('OpenRouter image processing', startTime, { activeSkill, imageSize: imageBuffer.length, responseLength: finalResponse.length, requestId: this.requestCount }); + return { response: finalResponse, metadata: { skill: activeSkill, programmingLanguage, processingTime: Date.now() - startTime, requestId: this.requestCount, usedFallback: false, isImageAnalysis: true, mimeType } }; + } catch (error) { + this.errorCount++; + logger.error('OpenRouter image processing failed', { error: error.message, activeSkill, requestId: this.requestCount }); + if (config.get('llm.openrouter.fallbackEnabled')) return this.generateFallbackResponse('[image]', activeSkill); + throw error; + } + } + + async processImageWithSkillStream(imageBuffer, mimeType, activeSkill, sessionMemory, programmingLanguage, onDelta) { + if (sessionMemory === undefined) sessionMemory = []; + if (programmingLanguage === undefined) programmingLanguage = null; + if (onDelta === undefined) onDelta = null; + if (!this.isInitialized) throw new Error('OpenRouter service not initialized. Check OPENROUTER_API_KEY configuration.'); + if (!imageBuffer || !Buffer.isBuffer(imageBuffer)) throw new Error('Invalid image buffer provided to processImageWithSkillStream'); + + const startTime = Date.now(); + this.requestCount++; + try { + const skillPrompt = promptLoader.getSkillPrompt(activeSkill, programmingLanguage) || ''; + const base64 = imageBuffer.toString('base64'); + const messages = this._buildImageMessages(base64, mimeType, activeSkill, programmingLanguage, skillPrompt); + const fullText = await this._executeStreamingRequest(messages, function(delta) { if (typeof onDelta === 'function' && delta) onDelta(delta); }); + const finalResponse = programmingLanguage ? this.enforceProgrammingLanguage(fullText, programmingLanguage) : fullText; + logger.logPerformance('OpenRouter image streaming', startTime, { activeSkill, imageSize: imageBuffer.length, responseLength: finalResponse.length, requestId: this.requestCount }); + return { response: finalResponse, metadata: { skill: activeSkill, programmingLanguage, processingTime: Date.now() - startTime, requestId: this.requestCount, usedFallback: false, streamed: true, isImageAnalysis: true, mimeType } }; + } catch (error) { + logger.warn('OpenRouter streaming image failed, falling back to non-streaming', { error: error.message }); + return this.processImageWithSkill(imageBuffer, mimeType, activeSkill, sessionMemory, programmingLanguage); + } + } + + async processTextWithSkill(text, activeSkill, sessionMemory, programmingLanguage) { + if (sessionMemory === undefined) sessionMemory = []; + if (programmingLanguage === undefined) programmingLanguage = null; + if (!this.isInitialized) throw new Error('OpenRouter service not initialized. Check OPENROUTER_API_KEY configuration.'); + + const startTime = Date.now(); + this.requestCount++; + try { + logger.info('Processing text with OpenRouter', { activeSkill, textLength: text.length, requestId: this.requestCount }); + const messages = this._buildTextMessages(text, activeSkill, sessionMemory, programmingLanguage); + const responseText = await this._executeRequest(messages); + const finalResponse = programmingLanguage ? this.enforceProgrammingLanguage(responseText, programmingLanguage) : responseText; + logger.logPerformance('OpenRouter text processing', startTime, { activeSkill, textLength: text.length, responseLength: finalResponse.length, requestId: this.requestCount }); + return { response: finalResponse, metadata: { skill: activeSkill, programmingLanguage, processingTime: Date.now() - startTime, requestId: this.requestCount, usedFallback: false } }; + } catch (error) { + this.errorCount++; + logger.error('OpenRouter text processing failed', { error: error.message, activeSkill, requestId: this.requestCount }); + if (config.get('llm.openrouter.fallbackEnabled')) return this.generateFallbackResponse(text, activeSkill); + throw error; + } + } + + async processTextWithSkillStream(text, activeSkill, sessionMemory, programmingLanguage, onDelta) { + if (sessionMemory === undefined) sessionMemory = []; + if (programmingLanguage === undefined) programmingLanguage = null; + if (onDelta === undefined) onDelta = null; + if (!this.isInitialized) throw new Error('OpenRouter service not initialized. Check OPENROUTER_API_KEY configuration.'); + + const startTime = Date.now(); + this.requestCount++; + try { + const messages = this._buildTextMessages(text, activeSkill, sessionMemory, programmingLanguage); + const fullText = await this._executeStreamingRequest(messages, function(delta) { if (typeof onDelta === 'function' && delta) onDelta(delta); }); + const finalResponse = programmingLanguage ? this.enforceProgrammingLanguage(fullText, programmingLanguage) : fullText; + logger.logPerformance('OpenRouter text streaming', startTime, { activeSkill, textLength: text.length, responseLength: finalResponse.length, requestId: this.requestCount }); + return { response: finalResponse, metadata: { skill: activeSkill, programmingLanguage, processingTime: Date.now() - startTime, requestId: this.requestCount, usedFallback: false, streamed: true } }; + } catch (error) { + logger.warn('OpenRouter streaming text failed, falling back to non-streaming', { error: error.message }); + return this.processTextWithSkill(text, activeSkill, sessionMemory, programmingLanguage); + } + } + + async processTranscriptionWithIntelligentResponse(text, activeSkill, sessionMemory, programmingLanguage) { + if (sessionMemory === undefined) sessionMemory = []; + if (programmingLanguage === undefined) programmingLanguage = null; + if (!this.isInitialized) throw new Error('OpenRouter service not initialized. Check OPENROUTER_API_KEY configuration.'); + + const startTime = Date.now(); + this.requestCount++; + try { + const cleanText = (text && typeof text === 'string') ? text.trim() : ''; + if (!cleanText) throw new Error('Empty transcription text'); + logger.info('Processing transcription with OpenRouter', { activeSkill, textLength: cleanText.length, requestId: this.requestCount }); + const messages = this._buildTranscriptionMessages(cleanText, activeSkill, sessionMemory, programmingLanguage); + const responseText = await this._executeRequest(messages); + const finalResponse = programmingLanguage ? this.enforceProgrammingLanguage(responseText, programmingLanguage) : responseText; + logger.logPerformance('OpenRouter transcription processing', startTime, { activeSkill, textLength: cleanText.length, responseLength: finalResponse.length, requestId: this.requestCount }); + return { response: finalResponse, metadata: { skill: activeSkill, programmingLanguage, processingTime: Date.now() - startTime, requestId: this.requestCount, usedFallback: false, isTranscriptionResponse: true } }; + } catch (error) { + this.errorCount++; + logger.error('OpenRouter transcription processing failed', { error: error.message, activeSkill, requestId: this.requestCount }); + if (config.get('llm.openrouter.fallbackEnabled')) return this.generateIntelligentFallbackResponse(text, activeSkill); + throw error; + } + } + + async processTranscriptionWithIntelligentResponseStream(text, activeSkill, sessionMemory, programmingLanguage, onDelta) { + if (sessionMemory === undefined) sessionMemory = []; + if (programmingLanguage === undefined) programmingLanguage = null; + if (onDelta === undefined) onDelta = null; + if (!this.isInitialized) throw new Error('OpenRouter service not initialized. Check OPENROUTER_API_KEY configuration.'); + + const startTime = Date.now(); + this.requestCount++; + try { + const cleanText = (text && typeof text === 'string') ? text.trim() : ''; + if (!cleanText) throw new Error('Empty transcription text'); + const messages = this._buildTranscriptionMessages(cleanText, activeSkill, sessionMemory, programmingLanguage); + const fullText = await this._executeStreamingRequest(messages, function(delta) { if (typeof onDelta === 'function' && delta) onDelta(delta); }); + const finalResponse = programmingLanguage ? this.enforceProgrammingLanguage(fullText, programmingLanguage) : fullText; + logger.logPerformance('OpenRouter transcription streaming', startTime, { activeSkill, textLength: cleanText.length, responseLength: finalResponse.length, requestId: this.requestCount }); + return { response: finalResponse, metadata: { skill: activeSkill, programmingLanguage, processingTime: Date.now() - startTime, requestId: this.requestCount, usedFallback: false, streamed: true, isTranscriptionResponse: true } }; + } catch (error) { + logger.warn('OpenRouter streaming transcription failed, falling back to non-streaming', { error: error.message }); + return this.processTranscriptionWithIntelligentResponse(text, activeSkill, sessionMemory, programmingLanguage); + } + } + + // Pure-text fallback — identical logic to llm.service.js (provider-agnostic) + generateIntelligentFallbackResponse(text, activeSkill) { + logger.info('Generating intelligent fallback response', { activeSkill }); + const skillKeywords = { + 'dsa': ['algorithm', 'data structure', 'array', 'tree', 'graph', 'sort', 'search', 'complexity', 'big o'], + 'programming': ['code', 'function', 'variable', 'class', 'method', 'bug', 'debug', 'syntax'], + 'system-design': ['scalability', 'database', 'architecture', 'microservice', 'load balancer', 'cache'], + 'behavioral': ['interview', 'experience', 'situation', 'leadership', 'conflict', 'team'], + 'sales': ['customer', 'deal', 'negotiation', 'price', 'revenue', 'prospect'], + 'presentation': ['slide', 'audience', 'public speaking', 'presentation', 'nervous'], + 'data-science': ['data', 'model', 'machine learning', 'statistics', 'analytics', 'python', 'pandas'], + 'devops': ['deployment', 'ci/cd', 'docker', 'kubernetes', 'infrastructure', 'monitoring'], + 'negotiation': ['negotiate', 'compromise', 'agreement', 'terms', 'conflict resolution'] + }; + var textLower = (text || '').toLowerCase(); + var relevantKeywords = skillKeywords[activeSkill] || []; + var hasRelevantKeywords = relevantKeywords.some(function(kw) { return textLower.indexOf(kw) !== -1; }); + var questionIndicators = ['how', 'what', 'why', 'when', 'where', 'can you', 'could you', 'should i', '?']; + var seemsLikeQuestion = questionIndicators.some(function(ind) { return textLower.indexOf(ind) !== -1; }); + var response = (hasRelevantKeywords || seemsLikeQuestion) + ? 'I\'m having trouble processing that right now, but it sounds like a ' + activeSkill + ' question. Could you rephrase or ask more specifically about what you need help with?' + : 'Yeah, I\'m listening. Ask your question relevant to ' + activeSkill + '.'; + return { response: response, metadata: { skill: activeSkill, processingTime: 0, requestId: this.requestCount, usedFallback: true, isTranscriptionResponse: true } }; + } + + async testConnection() { + if (!this.isInitialized) return { success: false, error: 'Service not initialized. Check OPENROUTER_API_KEY.' }; + try { + var networkCheck = await this.checkNetworkConnectivity(); + var hasNetworkIssues = networkCheck.tests.some(function(t) { return !t.success; }); + if (hasNetworkIssues) logger.warn('Network issues before OpenRouter test', networkCheck); + var startTime = Date.now(); + var messages = [{ role: 'user', content: 'Test connection. Please respond with "OK".' }]; + var responseText = await this._rawRequest(messages, { max_tokens: 16 }); + var latency = Date.now() - startTime; + logger.info('OpenRouter connection test successful', { response: responseText, latency: latency, model: this.model }); + return { success: true, response: responseText, latency: latency, model: this.model, networkConnectivity: networkCheck }; + } catch (error) { + var errorAnalysis = this.analyzeError(error); + logger.error('OpenRouter connection test failed', { error: error.message, errorAnalysis: errorAnalysis }); + var friendlyError = this._friendlyTestError(error, errorAnalysis); + return { success: false, error: friendlyError, errorType: (errorAnalysis && errorAnalysis.type) || 'UNKNOWN', errorAnalysis: errorAnalysis, networkConnectivity: await this.checkNetworkConnectivity().catch(function() { return null; }) }; + } + } + + async checkNetworkConnectivity() { + var connectivityTests = [ + { host: 'google.com', port: 443, name: 'Google (HTTPS)' }, + { host: 'openrouter.ai', port: 443, name: 'OpenRouter API Endpoint' } + ]; + var self = this; + var results = await Promise.allSettled(connectivityTests.map(function(test) { return self.testNetworkConnection(test); })); + var connectivity = { + timestamp: new Date().toISOString(), + tests: results.map(function(result, index) { + return Object.assign({}, connectivityTests[index], { + success: result.status === 'fulfilled' && result.value, + error: result.status === 'rejected' ? result.reason.message : null + }); + }) + }; + logger.info('Network connectivity check completed', connectivity); + return connectivity; + } + + testNetworkConnection(opts) { + var host = opts.host, port = opts.port; + return new Promise(function(resolve, reject) { + var net = require('net'); + var socket = new net.Socket(); + var timeout = setTimeout(function() { socket.destroy(); reject(new Error('Connection timeout to ' + host + ':' + port)); }, 5000); + socket.on('connect', function() { clearTimeout(timeout); socket.destroy(); resolve(true); }); + socket.on('error', function(err) { clearTimeout(timeout); reject(new Error('Connection failed to ' + host + ':' + port + ': ' + err.message)); }); + socket.connect(port, host); + }); + } + + updateApiKey(newApiKey) { + process.env.OPENROUTER_API_KEY = newApiKey; + this.isInitialized = false; + this.initializeClient(); + logger.info('OpenRouter API key updated and client reinitialized'); + } + + getStats() { + return { isInitialized: this.isInitialized, requestCount: this.requestCount, errorCount: this.errorCount, successRate: this.requestCount > 0 ? ((this.requestCount - this.errorCount) / this.requestCount) * 100 : 0, config: config.get('llm.openrouter') }; + } + + // ── Message builders ──────────────────────────────────────────────── + + _buildImageMessages(base64, mimeType, activeSkill, programmingLanguage, skillPrompt) { + var messages = []; + if (skillPrompt && skillPrompt.trim().length > 0) messages.push({ role: 'system', content: skillPrompt }); + var langNote = programmingLanguage ? ' Use only ' + programmingLanguage.toUpperCase() + ' for any code.' : ''; + var textInstruction = 'Analyze this image for a ' + activeSkill.toUpperCase() + ' question. Extract the problem concisely and provide the best possible solution with explanation and final code.' + langNote; + messages.push({ role: 'user', content: [{ type: 'image_url', image_url: { url: 'data:' + mimeType + ';base64,' + base64 } }, { type: 'text', text: textInstruction }] }); + return messages; + } + + _buildTextMessages(text, activeSkill, sessionMemory, programmingLanguage) { + var messages = []; + try { + var sessionManager = require('../managers/session.manager'); + if (sessionManager && typeof sessionManager.getConversationHistory === 'function') { + var skillContext = sessionManager.getSkillContext(activeSkill, programmingLanguage); + if (skillContext && skillContext.skillPrompt) messages.push({ role: 'system', content: skillContext.skillPrompt }); + var history = sessionManager.getConversationHistory(15); + for (var i = 0; i < history.length; i++) { + var event = history[i]; + if (event.role === 'system' || !event.content || !event.content.trim()) continue; + messages.push({ role: event.role === 'model' ? 'assistant' : 'user', content: event.content.trim() }); + } + } else { + var components = promptLoader.getRequestComponents(activeSkill, text, sessionMemory, programmingLanguage); + if (components.skillPrompt) messages.push({ role: 'system', content: components.skillPrompt }); + } + } catch (e) { /* session manager unavailable */ } + messages.push({ role: 'user', content: 'Context: ' + activeSkill.toUpperCase() + ' analysis request\n\nText to analyze:\n' + text }); + return messages; + } + + _buildTranscriptionMessages(text, activeSkill, sessionMemory, programmingLanguage) { + var messages = []; + messages.push({ role: 'system', content: this._getIntelligentTranscriptionPrompt(activeSkill, programmingLanguage) }); + try { + var sessionManager = require('../managers/session.manager'); + if (sessionManager && typeof sessionManager.getConversationHistory === 'function') { + var history = sessionManager.getConversationHistory(10); + var recent = history.filter(function(e) { return e.role !== 'system' && e.content && e.content.trim(); }).slice(-8); + for (var i = 0; i < recent.length; i++) { + var event = recent[i]; + messages.push({ role: event.role === 'model' ? 'assistant' : 'user', content: event.content.trim() }); + } + } + } catch (e) { /* no history */ } + messages.push({ role: 'user', content: text }); + return messages; + } + + _getIntelligentTranscriptionPrompt(activeSkill, programmingLanguage) { + var prompt = '# Intelligent Transcription Response System\n\nAssume you are asked a question in ' + activeSkill.toUpperCase() + ' mode. Your job is to intelligently respond to question/message with appropriate brevity.\nAssume you are in an interview and you need to perform best in ' + activeSkill.toUpperCase() + ' mode.\nAlways respond to the point, do not repeat the question or unnecessary information which is not related to ' + activeSkill + '.'; + + if (programmingLanguage) { + var lang = String(programmingLanguage).toLowerCase(); + var languageMap = { cpp: 'C++', c: 'C', python: 'Python', java: 'Java', javascript: 'JavaScript', js: 'JavaScript' }; + var fenceTagMap = { cpp: 'cpp', c: 'c', python: 'python', java: 'java', javascript: 'javascript', js: 'javascript' }; + var languageTitle = languageMap[lang] || (lang.charAt(0).toUpperCase() + lang.slice(1)); + var fenceTag = fenceTagMap[lang] || lang || 'text'; + prompt += '\n\nCODING CONTEXT: Respond ONLY in ' + languageTitle + '. All code blocks must use triple backticks with language tag ```' + fenceTag + '```. Do not include other languages unless explicitly asked.'; + } + + prompt += '\n\n## Response Rules:\n\n### If the transcription is casual conversation, greetings, or NOT related to ' + activeSkill + ':\n- Respond with: "Yeah, I\'m listening. Ask your question relevant to ' + activeSkill + '."\n- Or similar brief acknowledgments.\n\n### If the transcription IS relevant to ' + activeSkill + ' or is a follow-up question:\n- Provide a comprehensive, detailed response\n- Use bullet points, examples, and explanations\n- Focus on actionable insights and complete answers\n- Do not truncate or shorten your response\n\n## Response Format:\n- Keep responses detailed\n- Use bullet points for structured answers\n- Be encouraging and helpful\n- Stay focused on ' + activeSkill + '\n\nIf the user\'s input is a coding or DSA problem statement and contains no code, produce a complete, runnable solution in the selected programming language without asking for more details. Always include the final implementation in a properly tagged code block.\n\nRemember: Be intelligent about filtering - only provide detailed responses when the user actually needs help with ' + activeSkill + '.'; + + return prompt; + } + + // ── HTTP execution ────────────────────────────────────────────────── + + async _executeRequest(messages) { + var maxRetries = config.get('llm.openrouter.maxRetries') || 3; + var lastError = null; + for (var attempt = 1; attempt <= maxRetries; attempt++) { + try { + logger.debug('OpenRouter request attempt ' + attempt, { model: this.model }); + var text = await this._rawRequest(messages, { stream: false }); + logger.debug('OpenRouter request successful', { attempt: attempt, responseLength: text.length }); + return text; + } catch (error) { + var info = this.analyzeError(error); + lastError = error; + logger.warn('OpenRouter attempt ' + attempt + ' failed', { error: error.message, errorType: info.type }); + if (attempt === maxRetries) break; + if (info.type === 'AUTH_ERROR' || info.type === 'CREDITS_ERROR') break; + var delay = (info.isNetworkError ? 2500 : 1500) * attempt + Math.random() * 1000; + await this._delay(delay); + } + } + throw lastError || new Error('OpenRouter request failed after all retries'); + } + + _executeStreamingRequest(messages, onDelta) { + var timeout = config.get('llm.openrouter.timeout') || 60000; + var genConfig = config.get('llm.openrouter.generation') || {}; + var apiKey = this.apiKey; + var model = this.model; + var bodyObj = { + model: model, + messages: messages, + stream: true, + temperature: genConfig.temperature != null ? genConfig.temperature : 0.7, + max_tokens: genConfig.max_tokens || 4096 + }; + var body = JSON.stringify(bodyObj); + var options = { + hostname: OPENROUTER_HOST, path: OPENROUTER_PATH, method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + apiKey, + 'HTTP-Referer': HTTP_REFERER, + 'X-Title': X_TITLE, + 'Content-Length': Buffer.byteLength(body) + } + }; + return new Promise(function(resolve, reject) { + var req = https.request(options, function(res) { + if (res.statusCode !== 200) { + var errBody = ''; + res.on('data', function(c) { errBody += c; }); + res.on('end', function() { reject(new Error('HTTP ' + res.statusCode + ': ' + errBody)); }); + return; + } + var fullText = '', buffer = ''; + res.setEncoding('utf8'); + res.on('data', function(chunk) { + buffer += chunk; + var idx; + while ((idx = buffer.indexOf('\n')) !== -1) { + var line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line.indexOf('data:') !== 0) continue; + var payload = line.slice(5).trim(); + if (!payload || payload === '[DONE]') continue; + try { + var json = JSON.parse(payload); + var delta = json && json.choices && json.choices[0] && json.choices[0].delta && json.choices[0].delta.content; + if (delta) { fullText += delta; if (typeof onDelta === 'function') onDelta(delta); } + } catch (e) { /* partial JSON */ } + } + }); + res.on('end', function() { resolve(fullText.trim()); }); + res.on('error', function(err) { reject(new Error('Streaming response error: ' + err.message)); }); + }); + var timer = setTimeout(function() { req.destroy(); reject(new Error('OpenRouter streaming request timed out')); }, timeout); + req.on('error', function(err) { clearTimeout(timer); reject(new Error('Streaming request failed: ' + err.message)); }); + req.on('close', function() { clearTimeout(timer); }); + req.write(body); + req.end(); + }); + } + + _rawRequest(messages, extraParams) { + if (!extraParams) extraParams = {}; + var timeout = config.get('llm.openrouter.timeout') || 60000; + var genConfig = config.get('llm.openrouter.generation') || {}; + var apiKey = this.apiKey; + var model = this.model; + var bodyObj = { + model: model, + messages: messages, + stream: false, + temperature: genConfig.temperature != null ? genConfig.temperature : 0.7, + max_tokens: extraParams.max_tokens || genConfig.max_tokens || 4096 + }; + var body = JSON.stringify(bodyObj); + var options = { + hostname: OPENROUTER_HOST, path: OPENROUTER_PATH, method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': 'Bearer ' + apiKey, + 'HTTP-Referer': HTTP_REFERER, + 'X-Title': X_TITLE, + 'Content-Length': Buffer.byteLength(body) + } + }; + return new Promise(function(resolve, reject) { + var req = https.request(options, function(res) { + var data = ''; + res.on('data', function(chunk) { data += chunk; }); + res.on('end', function() { + try { + if (res.statusCode !== 200) { + var errMsg = 'HTTP ' + res.statusCode; + try { + var p = JSON.parse(data); + errMsg = (p.error && p.error.message) ? 'HTTP ' + res.statusCode + ': ' + p.error.message : 'HTTP ' + res.statusCode + ': ' + data; + } catch (e2) { errMsg = 'HTTP ' + res.statusCode + ': ' + data.substring(0, 300); } + reject(new Error(errMsg)); return; + } + var parsed = JSON.parse(data); + var content = parsed && parsed.choices && parsed.choices[0] && parsed.choices[0].message && parsed.choices[0].message.content; + if (typeof content !== 'string' || content.trim().length === 0) { reject(new Error('Empty or missing content in OpenRouter response')); return; } + resolve(content.trim()); + } catch (e) { reject(new Error('Failed to parse OpenRouter response: ' + e.message)); } + }); + res.on('error', function(err) { reject(new Error('Response error: ' + err.message)); }); + }); + var timer = setTimeout(function() { req.destroy(); reject(new Error('OpenRouter request timed out')); }, timeout); + req.on('error', function(err) { clearTimeout(timer); reject(new Error('Request failed: ' + err.message)); }); + req.on('close', function() { clearTimeout(timer); }); + req.write(body); + req.end(); + }); + } + + // ── Utilities ──────────────────────────────────────────────────────── + + enforceProgrammingLanguage(text, programmingLanguage) { + try { + if (!text || !programmingLanguage) return text; + var norm = String(programmingLanguage).toLowerCase(); + var fenceTagMap = { cpp: 'cpp', c: 'c', python: 'python', java: 'java', javascript: 'javascript', js: 'javascript' }; + var fenceTag = fenceTagMap[norm] || norm || 'text'; + var replacedBackticks = text.replace(/```([^\n]*)\n/g, function(match, info) { + var current = (info || '').trim(); + if (current.split(/\s+/)[0].toLowerCase() === fenceTag) return match; + return '```' + fenceTag + '\n'; + }); + return replacedBackticks.replace(/~~~([^\n]*)\n/g, function() { return '```' + fenceTag + '\n'; }); + } catch (e) { return text; } + } + + generateFallbackResponse(text, activeSkill) { + logger.info('Generating fallback response', { activeSkill: activeSkill }); + var fallbackResponses = { + 'dsa': 'This appears to be a data structures and algorithms problem. Consider breaking it down into smaller components and identifying the appropriate algorithm or data structure to use.', + 'system-design': 'For this system design question, consider scalability, reliability, and the trade-offs between different architectural approaches.', + 'programming': 'This looks like a programming challenge. Focus on understanding the requirements, edge cases, and optimal time/space complexity.', + 'default': 'I can help analyze this content. Please ensure your OpenRouter API key is properly configured for detailed analysis.' + }; + var response = fallbackResponses[activeSkill] || fallbackResponses.default; + return { response: response, metadata: { skill: activeSkill, processingTime: 0, requestId: this.requestCount, usedFallback: true } }; + } + + analyzeError(error) { + var msg = (error.message || '').toLowerCase(); + if (msg.indexOf('enotfound') !== -1 || msg.indexOf('econnrefused') !== -1 || msg.indexOf('network error') !== -1) return { type: 'NETWORK_ERROR', isNetworkError: true, suggestedAction: 'Check internet connection' }; + if (msg.indexOf('401') !== -1 || msg.indexOf('unauthorized') !== -1 || msg.indexOf('invalid api key') !== -1) return { type: 'AUTH_ERROR', isNetworkError: false, suggestedAction: 'Verify OpenRouter API key' }; + if (msg.indexOf('402') !== -1 || msg.indexOf('insufficient credits') !== -1 || msg.indexOf('payment') !== -1) return { type: 'CREDITS_ERROR', isNetworkError: false, suggestedAction: 'Add credits to your OpenRouter account' }; + if (msg.indexOf('429') !== -1 || msg.indexOf('rate limit') !== -1 || msg.indexOf('too many requests') !== -1) return { type: 'RATE_LIMIT_ERROR', isNetworkError: false, suggestedAction: 'Wait before retrying' }; + if (msg.indexOf('timeout') !== -1 || msg.indexOf('etimedout') !== -1) return { type: 'TIMEOUT_ERROR', isNetworkError: true, suggestedAction: 'Check network latency' }; + if (msg.indexOf('503') !== -1 || msg.indexOf('unavailable') !== -1 || msg.indexOf('overloaded') !== -1) return { type: 'RATE_LIMIT_ERROR', isNetworkError: false, suggestedAction: 'OpenRouter is experiencing high load, please retry' }; + return { type: 'UNKNOWN_ERROR', isNetworkError: false, suggestedAction: 'Check logs for more details' }; + } + + _friendlyTestError(error, analysis) { + var type = analysis && analysis.type; + var raw = ((error && error.message) || '').toLowerCase(); + if (type === 'NETWORK_ERROR' || raw.indexOf('enotfound') !== -1) return 'Cannot reach OpenRouter servers. Check your internet connection, firewall, or VPN settings.'; + if (type === 'AUTH_ERROR' || raw.indexOf('401') !== -1) return 'Invalid API key. Double-check your OpenRouter key at openrouter.ai/keys.'; + if (type === 'CREDITS_ERROR' || raw.indexOf('402') !== -1) return 'Insufficient credits. Add credits to your OpenRouter account at openrouter.ai/credits.'; + if (type === 'RATE_LIMIT_ERROR' || raw.indexOf('429') !== -1) return 'Rate limit exceeded. Wait a moment or check your OpenRouter usage limits.'; + if (type === 'TIMEOUT_ERROR') return 'Request timed out. The OpenRouter API may be slow or unreachable right now.'; + if (raw.indexOf('503') !== -1 || raw.indexOf('overloaded') !== -1) return 'OpenRouter is experiencing high demand. Please wait a moment and try again.'; + return (error && error.message) || 'Connection to OpenRouter failed.'; + } + + _delay(ms) { return new Promise(function(resolve) { setTimeout(resolve, ms); }); } +} + +module.exports = new OpenRouterService(); diff --git a/src/ui/settings-window.js b/src/ui/settings-window.js index f3af65c..1f5b426 100644 --- a/src/ui/settings-window.js +++ b/src/ui/settings-window.js @@ -21,6 +21,10 @@ document.addEventListener('DOMContentLoaded', () => { const codingLanguageSelect = document.getElementById('codingLanguage'); const activeSkillSelect = document.getElementById('activeSkill'); const iconGrid = document.getElementById('iconGrid'); + // OpenRouter provider elements + const llmProviderSelect = document.getElementById('llmProvider'); + const openrouterKeyInput = document.getElementById('openrouterKey'); + const openrouterModelInput = document.getElementById('openrouterModel'); // Check if window.api exists if (!window.api) { @@ -89,6 +93,10 @@ document.addEventListener('DOMContentLoaded', () => { if (whisperSegmentMsInput) whisperSegmentMsInput.value = settings.whisperSegmentMs || ''; if (geminiKeyInput) geminiKeyInput.value = settings.geminiKey || ''; if (windowGapInput) windowGapInput.value = settings.windowGap || ''; + // OpenRouter / provider fields + if (llmProviderSelect) llmProviderSelect.value = settings.llmProvider || 'gemini'; + if (openrouterKeyInput) openrouterKeyInput.value = settings.openrouterKey || ''; + if (openrouterModelInput) openrouterModelInput.value = settings.openrouterModel || ''; // Set C++ as default if no coding language is specified if (codingLanguageSelect) { @@ -111,6 +119,7 @@ document.addEventListener('DOMContentLoaded', () => { } updateSpeechFieldStates(); + updateLLMProviderFieldStates(); }; // Load settings when window opens @@ -150,6 +159,10 @@ document.addEventListener('DOMContentLoaded', () => { if (windowGapInput) settings.windowGap = windowGapInput.value; if (codingLanguageSelect) settings.codingLanguage = codingLanguageSelect.value; if (activeSkillSelect) settings.activeSkill = activeSkillSelect.value; + // OpenRouter / provider fields + if (llmProviderSelect) settings.llmProvider = llmProviderSelect.value; + if (openrouterKeyInput) settings.openrouterKey = openrouterKeyInput.value; + if (openrouterModelInput) settings.openrouterModel = openrouterModelInput.value; window.api.send('save-settings', settings); }; @@ -184,6 +197,17 @@ document.addEventListener('DOMContentLoaded', () => { }); }; + const updateLLMProviderFieldStates = () => { + const selectedProvider = llmProviderSelect ? llmProviderSelect.value : 'gemini'; + const geminiGroup = document.getElementById('geminiFields'); + const openrouterGroup = document.getElementById('openrouterFields'); + if (geminiGroup) geminiGroup.style.display = selectedProvider === 'gemini' ? '' : 'none'; + if (openrouterGroup) openrouterGroup.style.display = selectedProvider === 'openrouter' ? '' : 'none'; + if (geminiKeyInput) geminiKeyInput.disabled = selectedProvider !== 'gemini'; + if (openrouterKeyInput) openrouterKeyInput.disabled = selectedProvider !== 'openrouter'; + if (openrouterModelInput) openrouterModelInput.disabled = selectedProvider !== 'openrouter'; + }; + // Add event listeners for all inputs const inputs = [ azureKeyInput, @@ -213,6 +237,13 @@ document.addEventListener('DOMContentLoaded', () => { }); } + if (llmProviderSelect) { + llmProviderSelect.addEventListener('change', () => { + updateLLMProviderFieldStates(); + saveSettings(); + }); + } + // Language selection handler if (codingLanguageSelect) { codingLanguageSelect.addEventListener('change', (e) => { @@ -237,6 +268,7 @@ document.addEventListener('DOMContentLoaded', () => { } updateSpeechFieldStates(); + updateLLMProviderFieldStates(); // Initialize icon grid with correct paths const initializeIconGrid = () => { From bd1fa3a993cf5305fea5650fd54b86e51ddd9ed5 Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Mon, 10 Aug 2026 16:02:19 +0530 Subject: [PATCH 2/8] fix: lower default max_tokens from 4096 to 2000 to prevent OpenRouter 402 credit cap errors --- src/core/config.js | 2 +- src/services/openrouter.service.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/config.js b/src/core/config.js index 4feda0a..0c8cdcb 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -69,7 +69,7 @@ class ConfigManager { fallbackEnabled: true, generation: { temperature: 0.7, - max_tokens: 4096 + max_tokens: 2000 } } }, diff --git a/src/services/openrouter.service.js b/src/services/openrouter.service.js index ec8f62d..edf8987 100644 --- a/src/services/openrouter.service.js +++ b/src/services/openrouter.service.js @@ -370,7 +370,7 @@ class OpenRouterService { messages: messages, stream: true, temperature: genConfig.temperature != null ? genConfig.temperature : 0.7, - max_tokens: genConfig.max_tokens || 4096 + max_tokens: genConfig.max_tokens || 2000 }; var body = JSON.stringify(bodyObj); var options = { @@ -431,7 +431,7 @@ class OpenRouterService { messages: messages, stream: false, temperature: genConfig.temperature != null ? genConfig.temperature : 0.7, - max_tokens: extraParams.max_tokens || genConfig.max_tokens || 4096 + max_tokens: extraParams.max_tokens || genConfig.max_tokens || 2000 }; var body = JSON.stringify(bodyObj); var options = { From 816100c918cdeeaac40e8e3691cc17ecba3cb2ce Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Mon, 10 Aug 2026 17:09:17 +0530 Subject: [PATCH 3/8] feat: add system tray icon with Open Settings and Quit menu items --- main.js | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/main.js b/main.js index be3e412..e6f875b 100644 --- a/main.js +++ b/main.js @@ -296,6 +296,8 @@ class ApplicationController { currentDesktop: "detected", }); + this.setupTray(); + sessionManager.addEvent("Application started"); } catch (error) { this.starting = false; @@ -306,6 +308,42 @@ class ApplicationController { } } + setupTray() { + try { + const { Tray, Menu } = require("electron"); + const path = require("path"); + const iconPath = path.resolve(__dirname, "assests/icons/terminal.png"); + + this.tray = new Tray(iconPath); + this.tray.setToolTip("OpenCluely AI Assistant"); + + const contextMenu = Menu.buildFromTemplate([ + { + label: "Open Settings", + click: () => { + windowManager.showWindow("settings"); + } + }, + { type: "separator" }, + { + label: "Quit OpenCluely", + click: () => { + app.quit(); + } + } + ]); + + this.tray.setContextMenu(contextMenu); + this.tray.on("double-click", () => { + windowManager.showWindow("settings"); + }); + + logger.info("System Tray initialized successfully"); + } catch (e) { + logger.warn("Failed to initialize System Tray", { error: e.message }); + } + } + setupNetworkConfiguration() { // Configure session to handle network requests better const ses = session.defaultSession; From 9f7de69d382a182f33d7922906e7459b1e3bdaac Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Mon, 10 Aug 2026 17:10:53 +0530 Subject: [PATCH 4/8] feat: add quit (X) button to main overlay command bar --- index.html | 4 ++++ src/ui/main-window.js | 17 ++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/index.html b/index.html index 31e37f5..24c55ac 100644 --- a/index.html +++ b/index.html @@ -382,6 +382,10 @@
+
+ +
+
diff --git a/src/ui/main-window.js b/src/ui/main-window.js index 2194ec9..e9b4634 100644 --- a/src/ui/main-window.js +++ b/src/ui/main-window.js @@ -270,8 +270,9 @@ class MainWindowUI { this.skillIndicator = document.getElementById('skillIndicator'); this.settingsIndicator = document.getElementById('settingsIndicator'); // Optional this.micButton = document.getElementById('micButton'); - this.infoButton = document.getElementById('infoButton'); - this.shortcutsPopover = document.getElementById('shortcutsPopover'); + this.infoButton = document.getElementById('infoButton'); + this.quitButton = document.getElementById('quitButton'); + this.shortcutsPopover = document.getElementById('shortcutsPopover'); // NEW: Screenshot button is the first .command-item without id const commandItems = document.querySelectorAll('.command-item'); @@ -375,12 +376,22 @@ class MainWindowUI { // Info button / shortcuts popover if (this.infoButton && this.shortcutsPopover) { - this.infoButton.addEventListener('click', (e) => { + this.infoButton.addEventListener('click', (e) => { if (!this.isInteractive) return; e.stopPropagation(); this.toggleShortcutsPopover(); }); + // Quit button handler + if (this.quitButton) { + this.quitButton.addEventListener('click', (e) => { + e.stopPropagation(); + if (window.electronAPI && window.electronAPI.quit) { + window.electronAPI.quit(); + } + }); + } + // Hover to show this.infoButton.addEventListener('mouseenter', () => { if (!this.isInteractive) return; From adad5205c850f4c53c25e8083e545386bf8e4a43 Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Tue, 11 Aug 2026 20:10:00 +0530 Subject: [PATCH 5/8] feat: Add System Design, Code Explanation, and Aptitude skills --- main.js | 44 ++---------------------------- prompt-loader.js | 10 +++---- prompts/aptitude.md | 9 ++++++ prompts/code-explanation.md | 10 +++++++ prompts/system-design.md | 10 +++++++ settings.html | 3 ++ src/services/llm.service.js | 6 +++- src/services/openrouter.service.js | 6 +++- src/ui/chat-window.js | 4 ++- src/ui/main-window.js | 12 ++++++-- 10 files changed, 62 insertions(+), 52 deletions(-) create mode 100644 prompts/aptitude.md create mode 100644 prompts/code-explanation.md create mode 100644 prompts/system-design.md diff --git a/main.js b/main.js index e6f875b..ee7d56b 100644 --- a/main.js +++ b/main.js @@ -296,8 +296,6 @@ class ApplicationController { currentDesktop: "detected", }); - this.setupTray(); - sessionManager.addEvent("Application started"); } catch (error) { this.starting = false; @@ -308,42 +306,6 @@ class ApplicationController { } } - setupTray() { - try { - const { Tray, Menu } = require("electron"); - const path = require("path"); - const iconPath = path.resolve(__dirname, "assests/icons/terminal.png"); - - this.tray = new Tray(iconPath); - this.tray.setToolTip("OpenCluely AI Assistant"); - - const contextMenu = Menu.buildFromTemplate([ - { - label: "Open Settings", - click: () => { - windowManager.showWindow("settings"); - } - }, - { type: "separator" }, - { - label: "Quit OpenCluely", - click: () => { - app.quit(); - } - } - ]); - - this.tray.setContextMenu(contextMenu); - this.tray.on("double-click", () => { - windowManager.showWindow("settings"); - }); - - logger.info("System Tray initialized successfully"); - } catch (e) { - logger.warn("Failed to initialize System Tray", { error: e.message }); - } - } - setupNetworkConfiguration() { // Configure session to handle network requests better const ses = session.defaultSession; @@ -1126,7 +1088,7 @@ class ApplicationController { // Use image directly with LLM and active skill; do not send chat messages here const sessionHistory = sessionManager.getOptimizedHistory(); - const skillsRequiringProgrammingLanguage = ['dsa']; + const skillsRequiringProgrammingLanguage = ['dsa', 'code-explanation']; const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill); this._responseSeq = (this._responseSeq || 0) + 1; @@ -1192,7 +1154,7 @@ class ApplicationController { sessionManager.addUserInput(text, 'llm_input'); // Check if current skill needs programming language context - const skillsRequiringProgrammingLanguage = ['dsa']; + const skillsRequiringProgrammingLanguage = ['dsa', 'code-explanation']; const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill); this._responseSeq = (this._responseSeq || 0) + 1; @@ -1361,7 +1323,7 @@ class ApplicationController { }); // Check if current skill needs programming language context - const skillsRequiringProgrammingLanguage = ['dsa']; + const skillsRequiringProgrammingLanguage = ['dsa', 'code-explanation']; const needsProgrammingLanguage = skillsRequiringProgrammingLanguage.includes(this.activeSkill); // Stream the answer progressively to the configured speech target. diff --git a/prompt-loader.js b/prompt-loader.js index e57259a..218bc86 100644 --- a/prompt-loader.js +++ b/prompt-loader.js @@ -6,8 +6,7 @@ class PromptLoader { this.prompts = new Map(); this.promptsLoaded = false; this.skillPromptSent = new Set(); - // Focus only on DSA - this.skillsRequiringProgrammingLanguage = ['dsa']; + this.skillsRequiringProgrammingLanguage = ['dsa', 'code-explanation']; } /** @@ -28,7 +27,6 @@ class PromptLoader { for (const file of files) { if (file.endsWith('.md')) { const skillName = path.basename(file, '.md'); - if (skillName !== 'dsa') continue; // only keep DSA const filePath = path.join(promptsDir, file); const promptContent = fs.readFileSync(filePath, 'utf8'); @@ -354,7 +352,9 @@ STRICT REQUIREMENTS: 'distributed-systems': 'system-design', 'negotiation': 'negotiation', 'negotiating': 'negotiation', - 'conflict-resolution': 'negotiation' + 'conflict-resolution': 'negotiation', + 'code-explanation': 'code-explanation', + 'aptitude': 'aptitude' }; return skillMap[normalized] || normalized; @@ -368,7 +368,7 @@ STRICT REQUIREMENTS: if (!this.promptsLoaded) { this.loadPrompts(); } - return ['dsa']; + return Array.from(this.prompts.keys()); } /** diff --git a/prompts/aptitude.md b/prompts/aptitude.md new file mode 100644 index 0000000..0b9bdd8 --- /dev/null +++ b/prompts/aptitude.md @@ -0,0 +1,9 @@ +# APTITUDE Mode + +You are a logical reasoning and aptitude test assistant. + +STRICT REQUIREMENTS: +- Provide clear, step-by-step logical reasoning to arrive at the answer. +- Support math, logic puzzles, probability, sequence, and pattern recognition problems. +- Explain the underlying formula or concept. +- Be direct and do not use unnecessary conversational filler. diff --git a/prompts/code-explanation.md b/prompts/code-explanation.md new file mode 100644 index 0000000..fd75059 --- /dev/null +++ b/prompts/code-explanation.md @@ -0,0 +1,10 @@ +# CODE EXPLANATION Mode + +You are an expert developer helping to explain and break down code. + +STRICT REQUIREMENTS: +- Explain what the code does clearly and step-by-step. +- Detail the logic, syntax, and purpose of the provided code block. +- Identify potential edge cases or bugs. +- Offer improved or refactored versions only if it significantly enhances performance or readability. +- Maintain the specified programming language context when providing examples. diff --git a/prompts/system-design.md b/prompts/system-design.md new file mode 100644 index 0000000..b0b99d3 --- /dev/null +++ b/prompts/system-design.md @@ -0,0 +1,10 @@ +# SYSTEM DESIGN Mode + +You are an expert System Design interviewer and architect. + +STRICT REQUIREMENTS: +- Focus on scalability, reliability, databases, microservices, and high-level architecture. +- Identify trade-offs in design choices. +- Structure responses clearly with components (e.g., Load Balancers, API Gateways, Databases, Caching). +- Be concise but comprehensive. +- Do not write implementation code unless explicitly requested. diff --git a/settings.html b/settings.html index dd1b5e0..adadf71 100644 --- a/settings.html +++ b/settings.html @@ -318,6 +318,9 @@ diff --git a/src/services/llm.service.js b/src/services/llm.service.js index d615cb7..771b38d 100644 --- a/src/services/llm.service.js +++ b/src/services/llm.service.js @@ -1330,6 +1330,8 @@ Remember: Be intelligent about filtering - only provide detailed responses when 'dsa': 'This appears to be a data structures and algorithms problem. Consider breaking it down into smaller components and identifying the appropriate algorithm or data structure to use.', 'system-design': 'For this system design question, consider scalability, reliability, and the trade-offs between different architectural approaches.', 'programming': 'This looks like a programming challenge. Focus on understanding the requirements, edge cases, and optimal time/space complexity.', + 'code-explanation': 'This looks like code that needs explaining. Consider breaking down the syntax, logic, and overall functionality.', + 'aptitude': 'This appears to be an aptitude or reasoning question. Focus on logical steps to arrive at the solution.', 'default': 'I can help analyze this content. Please ensure your Gemini API key is properly configured for detailed analysis.' }; @@ -1359,7 +1361,9 @@ Remember: Be intelligent about filtering - only provide detailed responses when 'presentation': ['slide', 'audience', 'public speaking', 'presentation', 'nervous'], 'data-science': ['data', 'model', 'machine learning', 'statistics', 'analytics', 'python', 'pandas'], 'devops': ['deployment', 'ci/cd', 'docker', 'kubernetes', 'infrastructure', 'monitoring'], - 'negotiation': ['negotiate', 'compromise', 'agreement', 'terms', 'conflict resolution'] + 'negotiation': ['negotiate', 'compromise', 'agreement', 'terms', 'conflict resolution'], + 'code-explanation': ['explain', 'understand', 'how does this work', 'meaning', 'logic', 'trace'], + 'aptitude': ['math', 'puzzle', 'logic', 'reasoning', 'sequence', 'calculate', 'probability'] }; const textLower = text.toLowerCase(); diff --git a/src/services/openrouter.service.js b/src/services/openrouter.service.js index edf8987..632a34f 100644 --- a/src/services/openrouter.service.js +++ b/src/services/openrouter.service.js @@ -192,7 +192,9 @@ class OpenRouterService { 'presentation': ['slide', 'audience', 'public speaking', 'presentation', 'nervous'], 'data-science': ['data', 'model', 'machine learning', 'statistics', 'analytics', 'python', 'pandas'], 'devops': ['deployment', 'ci/cd', 'docker', 'kubernetes', 'infrastructure', 'monitoring'], - 'negotiation': ['negotiate', 'compromise', 'agreement', 'terms', 'conflict resolution'] + 'negotiation': ['negotiate', 'compromise', 'agreement', 'terms', 'conflict resolution'], + 'code-explanation': ['explain', 'understand', 'how does this work', 'meaning', 'logic', 'trace'], + 'aptitude': ['math', 'puzzle', 'logic', 'reasoning', 'sequence', 'calculate', 'probability'] }; var textLower = (text || '').toLowerCase(); var relevantKeywords = skillKeywords[activeSkill] || []; @@ -497,6 +499,8 @@ class OpenRouterService { 'dsa': 'This appears to be a data structures and algorithms problem. Consider breaking it down into smaller components and identifying the appropriate algorithm or data structure to use.', 'system-design': 'For this system design question, consider scalability, reliability, and the trade-offs between different architectural approaches.', 'programming': 'This looks like a programming challenge. Focus on understanding the requirements, edge cases, and optimal time/space complexity.', + 'code-explanation': 'This looks like code that needs explaining. Consider breaking down the syntax, logic, and overall functionality.', + 'aptitude': 'This appears to be an aptitude or reasoning question. Focus on logical steps to arrive at the solution.', 'default': 'I can help analyze this content. Please ensure your OpenRouter API key is properly configured for detailed analysis.' }; var response = fallbackResponses[activeSkill] || fallbackResponses.default; diff --git a/src/ui/chat-window.js b/src/ui/chat-window.js index e546fe0..e2691c2 100644 --- a/src/ui/chat-window.js +++ b/src/ui/chat-window.js @@ -306,7 +306,9 @@ class ChatWindowUI { 'programming': '💻', 'devops': '🚀', 'system-design': '🏗️', - 'negotiation': '🤝' + 'negotiation': '🤝', + 'code-explanation': '📝', + 'aptitude': '🧩' }; const icon = icons[skillName] || '🎯'; diff --git a/src/ui/main-window.js b/src/ui/main-window.js index e9b4634..c3e7be6 100644 --- a/src/ui/main-window.js +++ b/src/ui/main-window.js @@ -538,7 +538,9 @@ class MainWindowUI { 'programming': 'Programming', 'devops': 'DevOps', 'system-design': 'System Design', - 'negotiation': 'Negotiation' + 'negotiation': 'Negotiation', + 'code-explanation': 'Code Explanation', + 'aptitude': 'Aptitude' }; const displaySkill = skillNames[skill] || skill.toUpperCase(); @@ -777,7 +779,9 @@ class MainWindowUI { 'programming': 'Programming', 'devops': 'DevOps', 'system-design': 'System Design', - 'negotiation': 'Negotiation' + 'negotiation': 'Negotiation', + 'code-explanation': 'Code Explanation', + 'aptitude': 'Aptitude' }; logger.info('Updating skill indicator', { @@ -890,7 +894,9 @@ class MainWindowUI { 'programming': 'Programming', 'devops': 'DevOps', 'system-design': 'System Design', - 'negotiation': 'Negotiation' + 'negotiation': 'Negotiation', + 'code-explanation': 'Code Explanation', + 'aptitude': 'Aptitude' }; const displayName = skillNames[skill] || skill.toUpperCase(); From 08a7ba03775bea5d2672f0e38d20ff2565859b20 Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Tue, 11 Aug 2026 20:11:25 +0530 Subject: [PATCH 6/8] support for apti,system design,code explaination --- package-lock.json | 53 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 13 deletions(-) diff --git a/package-lock.json b/package-lock.json index 708a550..9744646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -842,7 +842,6 @@ "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1017,6 +1016,7 @@ "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^2.1.0", "async": "^3.2.4", @@ -1036,6 +1036,7 @@ "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.1.4", "graceful-fs": "^4.2.0", @@ -1058,6 +1059,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -1073,7 +1075,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/archiver-utils/node_modules/string_decoder": { "version": "1.1.1", @@ -1081,6 +1084,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -1200,6 +1204,7 @@ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", @@ -1615,6 +1620,7 @@ "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "buffer-crc32": "^0.2.13", "crc32-stream": "^4.0.2", @@ -1714,6 +1720,7 @@ "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "crc32": "bin/crc32.njs" }, @@ -1727,6 +1734,7 @@ "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^3.4.0" @@ -1912,7 +1920,6 @@ "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "builder-util": "24.13.1", @@ -2108,6 +2115,7 @@ "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "24.13.3", "archiver": "^5.3.1", @@ -2121,6 +2129,7 @@ "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -2136,6 +2145,7 @@ "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "universalify": "^2.0.0" }, @@ -2149,6 +2159,7 @@ "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">= 10.0.0" } @@ -2533,7 +2544,8 @@ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/fs-extra": { "version": "8.1.0", @@ -3122,7 +3134,8 @@ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/isbinaryfile": { "version": "5.0.4", @@ -3320,6 +3333,7 @@ "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "readable-stream": "^2.0.5" }, @@ -3333,6 +3347,7 @@ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", @@ -3348,7 +3363,8 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lazystream/node_modules/string_decoder": { "version": "1.1.1", @@ -3356,6 +3372,7 @@ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "safe-buffer": "~5.1.0" } @@ -3372,35 +3389,40 @@ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.difference": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.flatten": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.isplainobject": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/lodash.union": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/logform": { "version": "2.7.0", @@ -3769,6 +3791,7 @@ "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -3944,7 +3967,8 @@ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/progress": { "version": "2.0.3", @@ -4075,6 +4099,7 @@ "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "minimatch": "^5.1.0" } @@ -4490,6 +4515,7 @@ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", @@ -4708,7 +4734,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", @@ -4896,6 +4921,7 @@ "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "archiver-utils": "^3.0.4", "compress-commons": "^4.1.2", @@ -4911,6 +4937,7 @@ "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "glob": "^7.2.3", "graceful-fs": "^4.2.0", From 4979b146f748344035690dfabd88d7ef32dee078 Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Tue, 11 Aug 2026 22:26:31 +0530 Subject: [PATCH 7/8] fix: address PR review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - openrouter.service.js: reset state in initializeClient() before checking key so stale credentials/model are never kept across reinit calls; read OPENROUTER_MODEL directly from process.env so model changes apply at runtime without restart; clear timeout timer on res.end and res.error in both _executeStreamingRequest and _rawRequest to prevent timer buildup with keep-alive sockets. - main.js: reinitialize OpenRouter client when key OR model changes (not just key); call initializeClient() so the fresh process.env model is picked up immediately; allow saving an empty OPENROUTER_MODEL so users can clear the field and revert to the built-in default. - settings-window.js: add openrouterKeyInput and openrouterModelInput to the shared inputs autosave array so blur/change on those fields triggers saveSettings automatically (previously only the provider dropdown saved). - first-run.js: do not trigger the Gemini-only onboarding wizard when LLM_PROVIDER=openrouter — the wizard cannot collect an OpenRouter key and would loop forever. OpenRouter users are directed to Settings > AI Provider. --- main.js | 20 ++++++++++---------- src/core/first-run.js | 9 ++++++--- src/services/openrouter.service.js | 23 ++++++++++++++++------- src/ui/settings-window.js | 4 +++- 4 files changed, 35 insertions(+), 21 deletions(-) diff --git a/main.js b/main.js index ee7d56b..f01ee28 100644 --- a/main.js +++ b/main.js @@ -393,6 +393,7 @@ class ApplicationController { const shortcuts = { "CommandOrControl+Shift+S": () => this.triggerScreenshotOCR(), "CommandOrControl+Shift+V": () => windowManager.toggleVisibility(), + "CommandOrControl+Shift+A": () => windowManager.toggleAssessmentMode(), "CommandOrControl+Shift+I": () => windowManager.toggleInteraction(), "CommandOrControl+Shift+C": () => windowManager.switchToWindow("chat"), "CommandOrControl+Shift+\\": () => this.clearSessionMemory(), @@ -1692,8 +1693,8 @@ class ApplicationController { if (settings.openrouterKey !== undefined) { envUpdates.OPENROUTER_API_KEY = settings.openrouterKey; } - if (settings.openrouterModel !== undefined && settings.openrouterModel.trim()) { - envUpdates.OPENROUTER_MODEL = settings.openrouterModel.trim(); + if (settings.openrouterModel !== undefined) { + envUpdates.OPENROUTER_MODEL = String(settings.openrouterModel || '').trim(); } // Capture the previous whisper command BEFORE persisting — persistEnvUpdates @@ -1720,17 +1721,16 @@ class ApplicationController { } } - // If the OpenRouter key was saved and the current runtime service is - // OpenRouter, reinitialize its client so the key is picked up immediately. - if (settings.openrouterKey !== undefined && envUpdates.OPENROUTER_API_KEY !== undefined) { + // If the OpenRouter key or model was saved and the current runtime service is + // OpenRouter, reinitialize its client so changes take effect immediately. + if (envUpdates.OPENROUTER_API_KEY !== undefined || envUpdates.OPENROUTER_MODEL !== undefined) { try { - if (typeof llmService.updateApiKey === 'function' && - llmService.constructor && llmService.constructor.name === 'OpenRouterService') { - llmService.updateApiKey(settings.openrouterKey); - logger.info("OpenRouter service reinitialized after key update"); + if (llmService.constructor && llmService.constructor.name === 'OpenRouterService') { + llmService.initializeClient(); + logger.info("OpenRouter service reinitialized after settings update"); } } catch (e) { - logger.warn("Failed to reinitialize OpenRouter service after key update", { error: e.message }); + logger.warn("Failed to reinitialize OpenRouter service after settings update", { error: e.message }); } } diff --git a/src/core/first-run.js b/src/core/first-run.js index 7dca785..f40deec 100644 --- a/src/core/first-run.js +++ b/src/core/first-run.js @@ -33,9 +33,12 @@ class FirstRunManager { const content = this._readEnv(); const provider = (content.LLM_PROVIDER || 'gemini').trim(); if (provider === 'openrouter') { - // When OpenRouter is selected, check its key instead of Gemini's - const orKey = (content.OPENROUTER_API_KEY || '').trim(); - return !orKey || orKey === 'your_openrouter_key_here'; + // The onboarding wizard only has a Gemini key screen; running it when + // OpenRouter is selected would leave the user in an infinite loop + // (wizard completes but OPENROUTER_API_KEY is still unset → needsOnboarding + // returns true again on every launch). Direct OpenRouter users to + // Settings > AI Provider instead — never trigger wizard for them. + return false; } const gemini = (content.GEMINI_API_KEY || '').trim(); return !gemini || gemini === 'your_gemini_api_key_here'; diff --git a/src/services/openrouter.service.js b/src/services/openrouter.service.js index 632a34f..948196f 100644 --- a/src/services/openrouter.service.js +++ b/src/services/openrouter.service.js @@ -28,6 +28,11 @@ class OpenRouterService { // ── Initialization ───────────────────────────────────────────────── initializeClient() { + // Always reset state first so we never keep stale credentials/model. + this.apiKey = null; + this.model = null; + this.isInitialized = false; + const apiKey = config.getApiKey('OPENROUTER'); if (!apiKey || apiKey === 'your_openrouter_key_here') { logger.warn('OpenRouter API key not configured', { keyExists: !!apiKey }); @@ -35,7 +40,10 @@ class OpenRouterService { } try { this.apiKey = apiKey; - this.model = config.get('llm.openrouter.model') || 'anthropic/claude-sonnet-4'; + // Read model directly from process.env so settings changes apply + // immediately when initializeClient() is called after save (no restart needed). + const envModel = (process.env.OPENROUTER_MODEL || '').trim(); + this.model = envModel || config.get('llm.openrouter.model') || 'anthropic/claude-sonnet-4'; this.isInitialized = true; logger.info('OpenRouter client initialized successfully', { model: this.model }); } catch (error) { @@ -372,7 +380,7 @@ class OpenRouterService { messages: messages, stream: true, temperature: genConfig.temperature != null ? genConfig.temperature : 0.7, - max_tokens: genConfig.max_tokens || 2000 + max_tokens: genConfig.max_tokens || 3000 }; var body = JSON.stringify(bodyObj); var options = { @@ -390,7 +398,7 @@ class OpenRouterService { if (res.statusCode !== 200) { var errBody = ''; res.on('data', function(c) { errBody += c; }); - res.on('end', function() { reject(new Error('HTTP ' + res.statusCode + ': ' + errBody)); }); + res.on('end', function() { clearTimeout(timer); reject(new Error('HTTP ' + res.statusCode + ': ' + errBody)); }); return; } var fullText = '', buffer = ''; @@ -411,8 +419,8 @@ class OpenRouterService { } catch (e) { /* partial JSON */ } } }); - res.on('end', function() { resolve(fullText.trim()); }); - res.on('error', function(err) { reject(new Error('Streaming response error: ' + err.message)); }); + res.on('end', function() { clearTimeout(timer); resolve(fullText.trim()); }); + res.on('error', function(err) { clearTimeout(timer); reject(new Error('Streaming response error: ' + err.message)); }); }); var timer = setTimeout(function() { req.destroy(); reject(new Error('OpenRouter streaming request timed out')); }, timeout); req.on('error', function(err) { clearTimeout(timer); reject(new Error('Streaming request failed: ' + err.message)); }); @@ -433,7 +441,7 @@ class OpenRouterService { messages: messages, stream: false, temperature: genConfig.temperature != null ? genConfig.temperature : 0.7, - max_tokens: extraParams.max_tokens || genConfig.max_tokens || 2000 + max_tokens: extraParams.max_tokens || genConfig.max_tokens || 3000 }; var body = JSON.stringify(bodyObj); var options = { @@ -451,6 +459,7 @@ class OpenRouterService { var data = ''; res.on('data', function(chunk) { data += chunk; }); res.on('end', function() { + clearTimeout(timer); try { if (res.statusCode !== 200) { var errMsg = 'HTTP ' + res.statusCode; @@ -466,7 +475,7 @@ class OpenRouterService { resolve(content.trim()); } catch (e) { reject(new Error('Failed to parse OpenRouter response: ' + e.message)); } }); - res.on('error', function(err) { reject(new Error('Response error: ' + err.message)); }); + res.on('error', function(err) { clearTimeout(timer); reject(new Error('Response error: ' + err.message)); }); }); var timer = setTimeout(function() { req.destroy(); reject(new Error('OpenRouter request timed out')); }, timeout); req.on('error', function(err) { clearTimeout(timer); reject(new Error('Request failed: ' + err.message)); }); diff --git a/src/ui/settings-window.js b/src/ui/settings-window.js index 1f5b426..928603f 100644 --- a/src/ui/settings-window.js +++ b/src/ui/settings-window.js @@ -220,7 +220,9 @@ document.addEventListener('DOMContentLoaded', () => { whisperResponseTargetSelect, whisperSegmentMsInput, geminiKeyInput, - windowGapInput + windowGapInput, + openrouterKeyInput, + openrouterModelInput ]; inputs.forEach(input => { From 8ce534d3aefa295a332c1a3b2f93894ef74f5d48 Mon Sep 17 00:00:00 2001 From: ShlokNaidu Date: Tue, 11 Aug 2026 22:32:01 +0530 Subject: [PATCH 8/8] feat: system tray and window manager updates --- src/core/config.js | 2 +- src/managers/window.manager.js | 68 ++++++++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/src/core/config.js b/src/core/config.js index 0c8cdcb..fd4fe9d 100644 --- a/src/core/config.js +++ b/src/core/config.js @@ -69,7 +69,7 @@ class ConfigManager { fallbackEnabled: true, generation: { temperature: 0.7, - max_tokens: 2000 + max_tokens: 3000 } } }, diff --git a/src/managers/window.manager.js b/src/managers/window.manager.js index 52fa81b..0ea0853 100644 --- a/src/managers/window.manager.js +++ b/src/managers/window.manager.js @@ -25,6 +25,8 @@ class WindowManager { this.isInitialized = false; this.isInitializing = false; this.isRecording = false; + this.assessmentMode = false; + this.assessmentWatchdog = null; // Add debouncing to prevent excessive operations this.lastEnforceTime = 0; @@ -1543,6 +1545,11 @@ class WindowManager { this.screenCaptureAvailabilityWatcher = null; } + if (this.assessmentWatchdog) { + clearInterval(this.assessmentWatchdog); + this.assessmentWatchdog = null; + } + logger.info('All windows destroyed'); } @@ -1818,7 +1825,68 @@ class WindowManager { skill, windowCount: this.windows.size }); + } + + toggleAssessmentMode() { + this.assessmentMode = !this.assessmentMode; + + if (this.assessmentMode) { + logger.info('Assessment Mode enabled - activating watchdog'); + this.startAssessmentWatchdog(); + this.broadcastToAllWindows('assessment-mode-changed', { enabled: true }); + } else { + logger.info('Assessment Mode disabled - deactivating watchdog'); + this.stopAssessmentWatchdog(); + this.broadcastToAllWindows('assessment-mode-changed', { enabled: false }); + } + + return this.assessmentMode; + } + + startAssessmentWatchdog() { + if (this.assessmentWatchdog) { + clearInterval(this.assessmentWatchdog); + } + + this.assessmentWatchdog = setInterval(() => { + if (this.isVisible && !this.isScreenBeingShared) { + // Essential windows that MUST be visible when app is visible + const essentialWindows = ['main', 'chat']; + + this.windows.forEach((window, type) => { + if (!window.isDestroyed()) { + + // If it's an essential window, ensure it is completely visible + if (essentialWindows.includes(type) && !window.isVisible()) { + window.showInactive(); + } + + if (window.isMinimized()) { + window.restore(); + } + + // Re-assert always-on-top for any visible window + if (window.isVisible()) { + try { + if (process.platform === 'darwin') { + window.setAlwaysOnTop(true, 'screen-saver', 2); + } else { + window.setAlwaysOnTop(true); + } + } catch (e) {} + } + } + }); + } + }, 1000); + } + + stopAssessmentWatchdog() { + if (this.assessmentWatchdog) { + clearInterval(this.assessmentWatchdog); + this.assessmentWatchdog = null; } + } } module.exports = new WindowManager();